Modulo 22: Testing con xUnit

Objetivos del modulo

  • Entender la filosofia de xUnit.net.
  • Crear un proyecto xUnit v3 funcional en Visual Studio Community.
  • Desarrollar pruebas con [Fact], [Theory], [InlineData] y [MemberData].
  • Usar constructor, IDisposable, IAsyncLifetime, fixtures y colecciones.
  • Probar codigo de dominio, repositorios SQLite y APIs ASP.NET Core.
  • Comparar el flujo de trabajo de xUnit con NUnit.

1. Iniciacion

Concepto: xUnit.net es un framework de testing para .NET que favorece pruebas simples, aisladas y con menos atributos de ciclo de vida que NUnit.

La distincion principal es:

xUnit Significado
[Fact] Prueba fija, sin datos externos
[Theory] Prueba que se ejecuta con varios datos
Constructor Preparacion antes de cada test
IDisposable Limpieza despues de cada test
IAsyncLifetime Preparacion y limpieza asincrona
Fixtures Contexto compartido entre pruebas

En este modulo construiremos Tienda.XUnit, una solucion con biblioteca de dominio, API minima y pruebas xUnit v3.


2. Proyecto que vamos a construir

Tienda.XUnit/
  Tienda.XUnit.sln
  src/
    Tienda.Core/
      Calculadora.cs
      CalculadoraDescuentos.cs
      Carrito.cs
      PedidoProcessor.cs
      Producto.cs
      ProductoRepository.cs
      PromocionService.cs
    Tienda.Api/
      Program.cs
  tests/
    Tienda.Core.Tests/
      Unitarias/
      Integracion/
      Fixtures/
      Api/
      xunit.runner.json

El proyecto de pruebas es un Class Library configurado como ejecutable porque xUnit v3 usa Microsoft Testing Platform.


3. Crear la solucion en Visual Studio Community

  1. Crea una solucion vacia llamada Tienda.XUnit.
  2. Agrega un proyecto Class Library llamado Tienda.Core.
  3. Agrega un proyecto ASP.NET Core Empty llamado Tienda.Api.
  4. Agrega un proyecto Class Library llamado Tienda.Core.Tests.
  5. En Tienda.Api, agrega una referencia a Tienda.Core.
  6. En Tienda.Core.Tests, agrega referencias a Tienda.Core y Tienda.Api.
  7. En Tienda.Core, instala Microsoft.Data.Sqlite.
  8. En Tienda.Core.Tests, instala:
    • xunit.v3
    • xunit.runner.visualstudio
    • Microsoft.NET.Test.Sdk
    • Microsoft.AspNetCore.Mvc.Testing
  9. Edita el .csproj del proyecto de pruebas y agrega:
<PropertyGroup>
  <TargetFramework>net10.0</TargetFramework>
  <OutputType>Exe</OutputType>
  <ImplicitUsings>enable</ImplicitUsings>
  <Nullable>enable</Nullable>
  <IsPackable>false</IsPackable>
</PropertyGroup>
  1. Agrega un Using global para xUnit:
<ItemGroup>
  <Using Include="Xunit" />
</ItemGroup>
  1. Crea xunit.runner.json:
{
  "parallelizeTestCollections": true,
  "maxParallelThreads": 4,
  "methodDisplay": "method"
}
  1. Haz que el JSON se copie al directorio de salida:
<ItemGroup>
  <Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

4. Crear la solucion por terminal

dotnet new sln -n Tienda.XUnit -f sln
dotnet new classlib -n Tienda.Core -o src/Tienda.Core --framework net10.0
dotnet new web -n Tienda.Api -o src/Tienda.Api --framework net10.0
dotnet new classlib -n Tienda.Core.Tests -o tests/Tienda.Core.Tests --framework net10.0

dotnet sln Tienda.XUnit.sln add src/Tienda.Core/Tienda.Core.csproj src/Tienda.Api/Tienda.Api.csproj tests/Tienda.Core.Tests/Tienda.Core.Tests.csproj

dotnet add src/Tienda.Api/Tienda.Api.csproj reference src/Tienda.Core/Tienda.Core.csproj
dotnet add tests/Tienda.Core.Tests/Tienda.Core.Tests.csproj reference src/Tienda.Core/Tienda.Core.csproj src/Tienda.Api/Tienda.Api.csproj

dotnet add src/Tienda.Core/Tienda.Core.csproj package Microsoft.Data.Sqlite
dotnet add tests/Tienda.Core.Tests/Tienda.Core.Tests.csproj package xunit.v3
dotnet add tests/Tienda.Core.Tests/Tienda.Core.Tests.csproj package xunit.runner.visualstudio
dotnet add tests/Tienda.Core.Tests/Tienda.Core.Tests.csproj package Microsoft.NET.Test.Sdk
dotnet add tests/Tienda.Core.Tests/Tienda.Core.Tests.csproj package Microsoft.AspNetCore.Mvc.Testing

5. Primer [Fact]

Crea Calculadora.cs:

namespace Tienda.Core;

public class Calculadora
{
    public int Sumar(int a, int b) => a + b;
    public int Dividir(int a, int b) => a / b;
}

Crea Unitarias/CalculadoraTests.cs:

using Tienda.Core;

namespace Tienda.Core.Tests.Unitarias;

public class CalculadoraTests
{
    [Fact]
    public void Sumar_DosNumeros_DevuelveSuma()
    {
        Calculadora calculadora = new();

        int resultado = calculadora.Sumar(2, 3);

        Assert.Equal(5, resultado);
    }
}

Agrega una prueba de excepcion:

[Fact]
public void Dividir_EntreCero_LanzaExcepcion()
{
    Calculadora calculadora = new();

    Assert.Throws<DivideByZeroException>(() => calculadora.Dividir(10, 0));
}

6. [Theory] con [InlineData]

Cuando se comprueba la misma regla con varios valores, usa [Theory]:

[Theory]
[InlineData(2, 3, 5)]
[InlineData(-2, 2, 0)]
[InlineData(10, 15, 25)]
public void Sumar_VariosCasos_DevuelveSuma(int a, int b, int esperado)
{
    Calculadora calculadora = new();

    int resultado = calculadora.Sumar(a, b);

    Assert.Equal(esperado, resultado);
}

7. Datos externos con [MemberData]

Crea CalculadoraDescuentos.cs:

namespace Tienda.Core;

public class CalculadoraDescuentos
{
    public decimal Aplicar(decimal precio, decimal porcentaje)
    {
        if (precio < 0)
            throw new ArgumentOutOfRangeException(nameof(precio));

        if (porcentaje is < 0 or > 100)
            throw new ArgumentOutOfRangeException(nameof(porcentaje));

        return precio - precio * porcentaje / 100;
    }
}

Prueba con datos externos:

public static IEnumerable<object[]> CasosDescuento()
{
    yield return new object[] { 100m, 10m, 90m };
    yield return new object[] { 200m, 25m, 150m };
    yield return new object[] { 80m, 0m, 80m };
}

[Theory]
[MemberData(nameof(CasosDescuento))]
public void AplicarDescuento_CasosValidos(decimal precio, decimal descuento, decimal esperado)
{
    CalculadoraDescuentos calculadora = new();

    decimal resultado = calculadora.Aplicar(precio, descuento);

    Assert.Equal(esperado, resultado);
}

Tambien puedes usar un record:

public record CasoDescuento(decimal Precio, decimal Descuento, decimal Esperado);

8. Constructor y IDisposable

Crea Carrito.cs:

namespace Tienda.Core;

public class Carrito
{
    private readonly List<Producto> _productos = [];

    public int TotalItems => _productos.Count;
    public bool EstaVacio => _productos.Count == 0;

    public void Agregar(Producto producto) => _productos.Add(producto);
    public void Limpiar() => _productos.Clear();
}

Prueba:

public class CarritoTests : IDisposable
{
    private readonly Carrito _carrito;

    public CarritoTests()
    {
        _carrito = new Carrito();
    }

    [Fact]
    public void Carrito_Nuevo_EstaVacio()
    {
        Assert.Equal(0, _carrito.TotalItems);
        Assert.True(_carrito.EstaVacio);
    }

    public void Dispose()
    {
        _carrito.Limpiar();
    }
}

En xUnit, el constructor sustituye a [SetUp] y Dispose sustituye a [TearDown].


9. Fakes y pruebas asincronas

Crea PromocionService.cs:

namespace Tienda.Core;

public interface IClock
{
    DateTime UtcNow { get; }
}

public class FakeClock : IClock
{
    public DateTime UtcNow { get; set; }
}

public class PromocionService
{
    private readonly IClock _clock;

    public PromocionService(IClock clock)
    {
        _clock = clock;
    }

    public bool EstaActiva()
    {
        return _clock.UtcNow.DayOfWeek is DayOfWeek.Friday;
    }
}

Prueba:

[Fact]
public void EstaActiva_CuandoEsViernes_DevuelveTrue()
{
    FakeClock clock = new()
    {
        UtcNow = new DateTime(2026, 5, 29)
    };

    PromocionService service = new(clock);

    Assert.True(service.EstaActiva());
}

Para excepciones asincronas:

await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
    await service.ProcesarAsync(-1);
});

10. Salida de diagnostico

Inyecta ITestOutputHelper por constructor:

public class PedidoTests
{
    private readonly ITestOutputHelper _output;

    public PedidoTests(ITestOutputHelper output)
    {
        _output = output;
    }

    [Fact]
    public void CrearPedido_MuestraDiagnostico()
    {
        _output.WriteLine("Creando pedido de prueba...");

        Assert.True(true);
    }
}

Usa la salida para diagnosticar fallos, no como sustituto de aserciones.


11. Repositorio con SQLite e IAsyncLifetime

Crea ProductoRepository con estos metodos:

  • CrearParaTestsAsync.
  • InicializarAsync.
  • CrearAsync.
  • ObtenerTodosAsync.
  • DisposeAsync.

Despues crea un recurso de pruebas:

public sealed class TestDatabase : IAsyncDisposable
{
    public ProductoRepository Repository { get; private set; } = null!;

    public async Task InicializarAsync()
    {
        Repository = await ProductoRepository.CrearParaTestsAsync();
    }

    public async ValueTask DisposeAsync()
    {
        await Repository.DisposeAsync();
    }
}

Y una prueba con IAsyncLifetime:

public class RepositorioTests : IAsyncLifetime
{
    private TestDatabase _database = null!;

    public async ValueTask InitializeAsync()
    {
        _database = new TestDatabase();
        await _database.InicializarAsync();
    }

    public async ValueTask DisposeAsync()
    {
        await _database.DisposeAsync();
    }

    [Fact]
    public async Task CrearAsync_GuardaProducto()
    {
        int id = await _database.Repository.CrearAsync(new Producto("Teclado", 89.99m));

        Assert.True(id > 0);
    }
}

12. Fixtures de clase y coleccion

Fixture compartida por una clase:

public class DatabaseFixture : IAsyncLifetime
{
    public ProductoRepository Repository { get; private set; } = null!;

    public async ValueTask InitializeAsync()
    {
        Repository = await ProductoRepository.CrearParaTestsAsync();
    }

    public async ValueTask DisposeAsync()
    {
        await Repository.DisposeAsync();
    }
}

Uso con IClassFixture<T>:

public class ProductoRepositoryTests : IClassFixture<DatabaseFixture>
{
    private readonly DatabaseFixture _fixture;

    public ProductoRepositoryTests(DatabaseFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public async Task ObtenerTodosAsync_DevuelveProductos()
    {
        List<Producto> productos = await _fixture.Repository.ObtenerTodosAsync();

        Assert.NotNull(productos);
        Assert.NotEmpty(productos);
    }
}

Para compartir fixture entre varias clases:

[CollectionDefinition("BaseDatos")]
public class BaseDatosCollection : ICollectionFixture<DatabaseFixture>
{
}

13. API minima y WebApplicationFactory

En Tienda.Api/Program.cs, crea una API minima:

using Tienda.Core;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();

List<Producto> productos =
[
    new Producto("Teclado", 89.99m) { Id = 1, Categoria = "Perifericos", Stock = 12 },
    new Producto("Raton", 29.99m) { Id = 2, Categoria = "Perifericos", Stock = 20 }
];

app.MapGet("/api/productos", () => Results.Ok(productos));

app.Run();

public partial class Program
{
}

La clase parcial Program permite que el proyecto de pruebas cree la aplicacion en memoria.

Prueba:

using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;

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

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

    [Fact]
    public async Task GetProductos_DevuelveOk()
    {
        HttpResponseMessage response = await _client.GetAsync(
            "/api/productos",
            TestContext.Current.CancellationToken);

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }
}

14. Ejecucion

Desde Visual Studio:

  1. Compila la solucion.
  2. Abre Test > Test Explorer.
  3. Ejecuta todas las pruebas.
  4. Filtra por clase, metodo o namespace.

Desde terminal:

dotnet test
dotnet test --filter "FullyQualifiedName~Calculadora"
dotnet test tests/Tienda.Core.Tests/Tienda.Core.Tests.csproj

15. Plan docente recomendado

Este modulo funciona muy bien despues de NUnit, porque el alumno ya entiende que es una prueba y puede centrarse en las diferencias de estilo.

Tiempo Contenido Resultado esperado
20 min Comparar NUnit y xUnit El alumno entiende Fact, Theory y constructor
30 min Crear solucion xUnit v3 Proyecto detectable en Test Explorer
35 min Primeros facts y theories CalculadoraTests completo
35 min MemberData, records y casos complejos CalculadoraDescuentosTests
30 min Constructor, IDisposable y fakes CarritoTests y PromocionServiceTests
40 min IAsyncLifetime y fixtures Repositorio SQLite probado
35 min WebApplicationFactory API minima probada por HTTP
25 min Runner, paralelismo y diagnostico xunit.runner.json explicado
30 min Ejercicio guiado Endpoint o validador nuevo probado

16. Ruta de construccion guiada

Etapa 1: validar entorno xUnit v3

Antes de escribir logica real, comprueba que el runner funciona:

public class SmokeTests
{
    [Fact]
    public void ElRunner_DeberiaEjecutarTests()
    {
        Assert.True(true);
    }
}

Si no aparece en Test Explorer:

  • Revisa xunit.v3.
  • Revisa xunit.runner.visualstudio.
  • Revisa <OutputType>Exe</OutputType>.
  • Revisa <Using Include="Xunit" />.

Etapa 2: facts

Un Fact debe expresar una regla concreta:

[Fact]
public void Dividir_Dividendo10YDivisor2_Devuelve5()
{
    Calculadora calculadora = new();

    int resultado = calculadora.Dividir(10, 2);

    Assert.Equal(5, resultado);
}

Luego prueba el error:

[Fact]
public void Dividir_EntreCero_LanzaDivideByZeroException()
{
    Calculadora calculadora = new();

    Assert.Throws<DivideByZeroException>(() => calculadora.Dividir(10, 0));
}

Etapa 3: theories

Cuando la regla es la misma, evita duplicar tests:

[Theory]
[InlineData(10, 2, 5)]
[InlineData(9, 3, 3)]
[InlineData(8, 4, 2)]
public void Dividir_CasosValidos_DevuelveCociente(int a, int b, int esperado)
{
    Calculadora calculadora = new();

    int resultado = calculadora.Dividir(a, b);

    Assert.Equal(esperado, resultado);
}

Pregunta para clase: si una prueba tiene if dentro, quizas deberia dividirse en varios casos.

Etapa 4: datos con nombre

Cuando hay muchos datos, un record mejora legibilidad:

public record CasoDivision(int Dividendo, int Divisor, int Esperado);

public static IEnumerable<object[]> CasosDivision()
{
    yield return new object[] { new CasoDivision(10, 2, 5) };
    yield return new object[] { new CasoDivision(9, 3, 3) };
}

[Theory]
[MemberData(nameof(CasosDivision))]
public void Dividir_ConRecord_DevuelveCociente(CasoDivision caso)
{
    Calculadora calculadora = new();

    int resultado = calculadora.Dividir(caso.Dividendo, caso.Divisor);

    Assert.Equal(caso.Esperado, resultado);
}

Etapa 5: estado por prueba

Demuestra que xUnit crea una instancia nueva de la clase de test por cada prueba:

public class ContadorTests
{
    private int _contador;

    [Fact]
    public void PrimerTest_IncrementaContador()
    {
        _contador++;

        Assert.Equal(1, _contador);
    }

    [Fact]
    public void SegundoTest_TambienEmpiezaEnCero()
    {
        _contador++;

        Assert.Equal(1, _contador);
    }
}

Esto ayuda a entender por que el constructor sirve como setup por test.


17. Banco de ejemplos xUnit

17.1 Aserciones basicas

[Fact]
public void Producto_Valido_TieneDatosEsperados()
{
    Producto producto = new("Teclado", 89.99m)
    {
        Categoria = "Perifericos",
        Stock = 10
    };

    Assert.Equal("Teclado", producto.Nombre);
    Assert.Equal(89.99m, producto.Precio);
    Assert.True(producto.Stock > 0);
    Assert.Contains("Perifericos", producto.Categoria);
}

17.2 Aserciones sobre colecciones

[Fact]
public void Productos_DeberianEstarOrdenadosPorNombre()
{
    List<Producto> productos =
    [
        new Producto("Monitor", 199.99m),
        new Producto("Teclado", 89.99m)
    ];

    Assert.Collection(productos,
        primero => Assert.Equal("Monitor", primero.Nombre),
        segundo => Assert.Equal("Teclado", segundo.Nombre));
}

Cuando no importa el orden:

[Fact]
public void Productos_ContienePerifericos()
{
    List<Producto> productos =
    [
        new Producto("Teclado", 89.99m) { Categoria = "Perifericos" },
        new Producto("Monitor", 199.99m) { Categoria = "Pantallas" }
    ];

    Assert.Contains(productos, producto => producto.Categoria == "Perifericos");
}

17.3 ClassData

ClassData permite separar los datos en una clase reutilizable:

public class CasosDescuentoClassData : IEnumerable<object[]>
{
    public IEnumerator<object[]> GetEnumerator()
    {
        yield return new object[] { 100m, 10m, 90m };
        yield return new object[] { 200m, 25m, 150m };
        yield return new object[] { 80m, 0m, 80m };
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

Uso:

[Theory]
[ClassData(typeof(CasosDescuentoClassData))]
public void AplicarDescuento_ConClassData(decimal precio, decimal descuento, decimal esperado)
{
    CalculadoraDescuentos calculadora = new();

    decimal resultado = calculadora.Aplicar(precio, descuento);

    Assert.Equal(esperado, resultado);
}

Necesitaras:

using System.Collections;

17.4 Tests de validacion

Codigo:

public class UsuarioValidator
{
    public bool EsEmailValido(string email)
    {
        return !string.IsNullOrWhiteSpace(email) && email.Contains('@');
    }

    public bool EsMayorDeEdad(int edad)
    {
        return edad >= 18;
    }
}

Pruebas:

[Theory]
[InlineData("ana@ejemplo.com", true)]
[InlineData("sin-arroba", false)]
[InlineData("", false)]
[InlineData("   ", false)]
public void EsEmailValido_Casos_DevuelveResultado(string email, bool esperado)
{
    UsuarioValidator validator = new();

    bool resultado = validator.EsEmailValido(email);

    Assert.Equal(esperado, resultado);
}

[Theory]
[InlineData(17, false)]
[InlineData(18, true)]
[InlineData(65, true)]
public void EsMayorDeEdad_Casos_DevuelveResultado(int edad, bool esperado)
{
    UsuarioValidator validator = new();

    Assert.Equal(esperado, validator.EsMayorDeEdad(edad));
}

17.5 Tests async con cancelacion

Codigo:

public class ImportadorProductos
{
    public async Task<int> ImportarAsync(CancellationToken cancellationToken)
    {
        await Task.Delay(100, cancellationToken);
        return 10;
    }
}

Prueba:

[Fact]
public async Task ImportarAsync_Cancelado_LanzaTaskCanceledException()
{
    ImportadorProductos importador = new();
    using CancellationTokenSource cts = new();
    cts.Cancel();

    await Assert.ThrowsAsync<TaskCanceledException>(async () =>
        await importador.ImportarAsync(cts.Token));
}

17.6 Fixture con datos caros

public class CatalogoFixture
{
    public List<Producto> Productos { get; } =
    [
        new Producto("Teclado", 89.99m) { Categoria = "Perifericos" },
        new Producto("Monitor", 199.99m) { Categoria = "Pantallas" },
        new Producto("Webcam", 49.99m) { Categoria = "Perifericos" }
    ];
}

Uso:

public class CatalogoTests : IClassFixture<CatalogoFixture>
{
    private readonly CatalogoFixture _fixture;

    public CatalogoTests(CatalogoFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public void Productos_ContieneTresElementos()
    {
        Assert.Equal(3, _fixture.Productos.Count);
    }

    [Fact]
    public void Productos_ContienePerifericos()
    {
        Assert.Contains(_fixture.Productos, p => p.Categoria == "Perifericos");
    }
}

17.7 API: comprobar JSON

[Fact]
public async Task GetProductos_DevuelveJsonConProductos()
{
    HttpResponseMessage response = await _client.GetAsync(
        "/api/productos",
        TestContext.Current.CancellationToken);

    response.EnsureSuccessStatusCode();

    List<Producto>? productos = await response.Content.ReadFromJsonAsync<List<Producto>>(
        cancellationToken: TestContext.Current.CancellationToken);

    Assert.NotNull(productos);
    Assert.NotEmpty(productos);
    Assert.Contains(productos, p => p.Nombre == "Teclado");
}

Necesitaras:

using System.Net.Http.Json;

17.8 API: probar POST

[Fact]
public async Task PostProductos_DatosValidos_DevuelveCreated()
{
    Producto nuevo = new("Webcam", 49.99m)
    {
        Categoria = "Perifericos",
        Stock = 20
    };

    HttpResponseMessage response = await _client.PostAsJsonAsync(
        "/api/productos",
        nuevo,
        TestContext.Current.CancellationToken);

    Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}

17.9 Comparativa con NUnit

Necesidad NUnit xUnit
Prueba simple [Test] [Fact]
Datos inline [TestCase] [InlineData]
Datos externos [TestCaseSource] [MemberData]
Setup por test [SetUp] Constructor
Cleanup por test [TearDown] IDisposable
Setup async [SetUp] async IAsyncLifetime
Compartir recurso [OneTimeSetUp] IClassFixture<T>

18. Guion de explicacion en Visual Studio

  1. Ensena el proyecto NUnit anterior y pregunta que cambiaria en xUnit.
  2. Crea Calculadora y un [Fact].
  3. Convierte casos repetidos a [Theory].
  4. Introduce MemberData con descuentos.
  5. Explica que no hay [SetUp]: se usa constructor.
  6. Explica IDisposable.
  7. Introduce IAsyncLifetime con SQLite.
  8. Extrae DatabaseFixture.
  9. Crea una coleccion para compartir fixture.
  10. Crea la API minima.
  11. Prueba GET /api/productos.
  12. Prueba un POST.
  13. Muestra xunit.runner.json.
  14. Ejecuta desde Test Explorer y desde terminal.

19. Ejercicios

Ejercicio 1: Validador de usuarios

Valida email, password y edad minima con [Theory] e [InlineData].

Ejercicio 2: Calculadora de impuestos

Crea CalculadoraImpuestos y prueba varios tipos de IVA con [MemberData].

Ejercicio 3: Carrito con fixture

Crea una fixture con productos de ejemplo y prueba totales, descuentos y stock.

Ejercicio 4: API con WebApplicationFactory

Agrega endpoints GET /api/productos/{id} y POST /api/productos, y pruebalo.


Resumen

Concepto Sintaxis
Test fijo [Fact]
Test con datos [Theory]
Datos inline [InlineData(...)]
Datos externos [MemberData]
Setup por test Constructor
Cleanup por test IDisposable
Setup async IAsyncLifetime
Compartir contexto IClassFixture<T>
Compartir entre clases ICollectionFixture<T>
API en memoria WebApplicationFactory<Program>
Ejecutar dotnet test