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
- Crea una solucion vacia llamada
Tienda.XUnit. - Agrega un proyecto Class Library llamado
Tienda.Core. - Agrega un proyecto ASP.NET Core Empty llamado
Tienda.Api. - Agrega un proyecto Class Library llamado
Tienda.Core.Tests. - En
Tienda.Api, agrega una referencia aTienda.Core. - En
Tienda.Core.Tests, agrega referencias aTienda.CoreyTienda.Api. - En
Tienda.Core, instalaMicrosoft.Data.Sqlite. - En
Tienda.Core.Tests, instala:xunit.v3xunit.runner.visualstudioMicrosoft.NET.Test.SdkMicrosoft.AspNetCore.Mvc.Testing
- Edita el
.csprojdel proyecto de pruebas y agrega:
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
- Agrega un
Usingglobal para xUnit:
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
- Crea
xunit.runner.json:
{
"parallelizeTestCollections": true,
"maxParallelThreads": 4,
"methodDisplay": "method"
}
- 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:
- Compila la solucion.
- Abre Test > Test Explorer.
- Ejecuta todas las pruebas.
- 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
- Ensena el proyecto NUnit anterior y pregunta que cambiaria en xUnit.
- Crea
Calculadoray un[Fact]. - Convierte casos repetidos a
[Theory]. - Introduce
MemberDatacon descuentos. - Explica que no hay
[SetUp]: se usa constructor. - Explica
IDisposable. - Introduce
IAsyncLifetimecon SQLite. - Extrae
DatabaseFixture. - Crea una coleccion para compartir fixture.
- Crea la API minima.
- Prueba
GET /api/productos. - Prueba un
POST. - Muestra
xunit.runner.json. - 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 |
| Navegacion | |
|---|---|
| Modulo anterior: NUnit | Siguiente modulo: Selenium |