Módulo 06: Pruebas Unitarias con xUnit
Objetivos del módulo
- Configurar un proyecto de tests con xUnit
- Entender las diferencias entre xUnit y NUnit
- Dominar [Fact] y [Theory] con datos
- Usar inyección de dependencias en tests
- Controlar el ciclo de vida de los tests
1. ¿Qué es xUnit?
xUnit es el framework de testing más moderno para .NET. Fue creado por los mismos autores de NUnit v2 como evolución.
xUnit vs NUnit
| Aspecto | NUnit | xUnit |
|---|---|---|
| Test simple | [Test] | [Fact] |
| Test parametrizado | [TestCase] | [Theory] + [InlineData] |
| Setup | [SetUp] | Constructor |
| Teardown | [TearDown] | IDisposable |
| Clase de tests | [TestFixture] | No necesita atributo |
| Instancia | Reutilizada | Nueva por cada test |
| Aserciones | Assert.That() | Assert.Equal() |
📘 Concepto: En xUnit, cada test se ejecuta en una instancia nueva de la clase. Esto garantiza aislamiento total entre tests.
2. Configurar el proyecto
# Crear proyecto de tests xUnit
dotnet new xunit -n MiApp.XTests
dotnet sln add MiApp.XTests
dotnet add MiApp.XTests reference MiApp
Paquetes (ya incluidos)
<ItemGroup>
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
</ItemGroup>
3. [Fact] — Tests simples
[Fact] es equivalente a [Test] de NUnit: un test sin parámetros.
using MiApp;
namespace MiApp.XTests;
public class CalculadoraTests
{
private readonly Calculadora _calc = new(); // Constructor = SetUp
[Fact]
public void Sumar_DosPositivos_RetornaSuma()
{
// Arrange
int a = 2, b = 3;
// Act
int resultado = _calc.Sumar(a, b);
// Assert
Assert.Equal(5, resultado); // Assert.Equal(esperado, actual)
}
[Fact]
public void Dividir_PorCero_LanzaExcepcion()
{
var ex = Assert.Throws<DivideByZeroException>(
() => _calc.Dividir(10, 0)
);
Assert.Contains("cero", ex.Message);
}
}
4. [Theory] — Tests parametrizados
[InlineData] — Datos en línea
[Theory]
[InlineData(2, 3, 5)]
[InlineData(0, 0, 0)]
[InlineData(-1, 1, 0)]
[InlineData(100, -50, 50)]
public void Sumar_MultiplesValores_RetornaSumaCorrecta(int a, int b, int esperado)
{
int resultado = _calc.Sumar(a, b);
Assert.Equal(esperado, resultado);
}
[MemberData] — Datos desde propiedad o método
public static IEnumerable<object[]> DatosDeDivision()
{
yield return new object[] { 10.0, 2.0, 5.0 };
yield return new object[] { 9.0, 3.0, 3.0 };
yield return new object[] { 7.0, 2.0, 3.5 };
yield return new object[] { 100.0, 4.0, 25.0 };
}
[Theory]
[MemberData(nameof(DatosDeDivision))]
public void Dividir_MultiplesValores_RetornaCociente(double a, double b, double esperado)
{
double resultado = _calc.Dividir(a, b);
Assert.Equal(esperado, resultado, precision: 3);
}
[ClassData] — Datos desde una clase externa
// Clase que proporciona datos
public class DatosResta : IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
{
yield return new object[] { 10, 3, 7 };
yield return new object[] { 5, 5, 0 };
yield return new object[] { 0, 3, -3 };
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
// Test que usa la clase
[Theory]
[ClassData(typeof(DatosResta))]
public void Restar_MultiplesValores_RetornaDiferencia(int a, int b, int esperado)
{
Assert.Equal(esperado, _calc.Restar(a, b));
}
5. Aserciones de xUnit
Igualdad y comparación
Assert.Equal(5, resultado); // ==
Assert.NotEqual(0, resultado); // !=
Assert.Equal("hola", texto); // strings
Assert.Equal(9.99, precio, precision: 2); // doubles con precisión
Booleanos y nulos
Assert.True(condicion);
Assert.False(condicion);
Assert.Null(objeto);
Assert.NotNull(objeto);
Strings
Assert.Contains("mundo", texto); // contiene
Assert.StartsWith("Hola", texto); // empieza con
Assert.EndsWith("!", texto); // termina con
Assert.Matches(@"\d{3}", texto); // regex
Assert.Empty(texto); // cadena vacía
Colecciones
Assert.Contains(5, lista); // contiene elemento
Assert.DoesNotContain(99, lista); // no contiene
Assert.Empty(lista); // vacía
Assert.NotEmpty(lista); // no vacía
Assert.Single(lista); // exactamente 1 elemento
Assert.Equal(3, lista.Count); // tamaño
Assert.All(lista, item => Assert.True(item > 0)); // todos cumplen condición
Assert.Collection(lista, // verificar en orden
item => Assert.Equal(1, item),
item => Assert.Equal(2, item),
item => Assert.Equal(3, item)
);
Excepciones
// Verificar tipo de excepción
Assert.Throws<ArgumentException>(() => metodo(null));
// Capturar y verificar mensaje
var ex = Assert.Throws<ArgumentException>(() => metodo(null));
Assert.Contains("no puede ser null", ex.Message);
// Excepciones async
await Assert.ThrowsAsync<InvalidOperationException>(
async () => await metodoAsync()
);
Tipos
Assert.IsType<Perro>(animal); // tipo exacto
Assert.IsAssignableFrom<Animal>(animal); // tipo o derivado
6. Ciclo de vida en xUnit
Constructor = SetUp
public class MiTests : IDisposable
{
private readonly Calculadora _calc;
private readonly FileStream _stream;
// Constructor = Se ejecuta ANTES de cada test (nueva instancia)
public MiTests()
{
_calc = new Calculadora();
_stream = File.Create("temp_test.txt");
}
[Fact]
public void Test1() { /* ... */ }
[Fact]
public void Test2() { /* ... */ }
// Dispose = Se ejecuta DESPUÉS de cada test
public void Dispose()
{
_stream.Dispose();
File.Delete("temp_test.txt");
}
}
IClassFixture — Recurso compartido entre tests de una clase
// Fixture: se crea UNA vez para toda la clase
public class DatabaseFixture : IDisposable
{
public string ConnectionString { get; }
public DatabaseFixture()
{
ConnectionString = "Data Source=test.db";
// Crear BD de test
}
public void Dispose()
{
// Limpiar BD de test
}
}
// Los tests comparten el fixture
public class UsuarioTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _fixture;
public UsuarioTests(DatabaseFixture fixture)
{
_fixture = fixture; // Inyectado por xUnit
}
[Fact]
public void CrearUsuario_DatosValidos_RetornaId()
{
// Usar _fixture.ConnectionString
}
}
ICollectionFixture — Recurso compartido entre clases
// Definir la colección
[CollectionDefinition("Base de datos")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture> { }
// Clase 1 usa el fixture compartido
[Collection("Base de datos")]
public class UsuarioTests
{
private readonly DatabaseFixture _fixture;
public UsuarioTests(DatabaseFixture fixture) => _fixture = fixture;
}
// Clase 2 usa el MISMO fixture
[Collection("Base de datos")]
public class ProductoTests
{
private readonly DatabaseFixture _fixture;
public ProductoTests(DatabaseFixture fixture) => _fixture = fixture;
}
7. Output y diagnóstico
ITestOutputHelper
public class MiTests
{
private readonly ITestOutputHelper _output;
public MiTests(ITestOutputHelper output)
{
_output = output; // Inyectado por xUnit
}
[Fact]
public void Test_ConOutput()
{
var resultado = 2 + 2;
_output.WriteLine($"El resultado es: {resultado}");
Assert.Equal(4, resultado);
}
}
⚠️ Importante: No uses
Console.WriteLineen tests de xUnit. UsaITestOutputHelperpara que la salida se asocie al test correcto.
8. Ejemplo completo: Servicio de carrito
Código a probar
// MiApp/CarritoService.cs
namespace MiApp;
public class CarritoService
{
private readonly List<(string producto, decimal precio, int cantidad)> _items = new();
public void Agregar(string producto, decimal precio, int cantidad = 1)
{
if (string.IsNullOrWhiteSpace(producto))
throw new ArgumentException("Producto no puede estar vacío");
if (precio <= 0)
throw new ArgumentException("Precio debe ser positivo");
if (cantidad <= 0)
throw new ArgumentException("Cantidad debe ser positiva");
_items.Add((producto, precio, cantidad));
}
public int TotalItems => _items.Sum(i => i.cantidad);
public decimal Total => _items.Sum(i => i.precio * i.cantidad);
public decimal TotalConDescuento(decimal porcentaje)
{
if (porcentaje < 0 || porcentaje > 100)
throw new ArgumentOutOfRangeException(nameof(porcentaje));
return Total * (1 - porcentaje / 100);
}
public void Vaciar() => _items.Clear();
}
Tests
namespace MiApp.XTests;
public class CarritoServiceTests
{
private readonly CarritoService _carrito = new();
[Fact]
public void Agregar_ProductoValido_IncrementaItems()
{
_carrito.Agregar("Camiseta", 19.99m);
Assert.Equal(1, _carrito.TotalItems);
}
[Fact]
public void Agregar_MultiplesProductos_SumaTotal()
{
_carrito.Agregar("Camiseta", 19.99m, 2);
_carrito.Agregar("Pantalón", 39.99m, 1);
Assert.Equal(3, _carrito.TotalItems);
Assert.Equal(79.97m, _carrito.Total); // 19.99*2 + 39.99
}
[Theory]
[InlineData("", 10, "vacío")]
[InlineData("Prod", 0, "positivo")]
[InlineData("Prod", -5, "positivo")]
public void Agregar_DatosInvalidos_LanzaExcepcion(
string producto, decimal precio, string mensajeEsperado)
{
var ex = Assert.Throws<ArgumentException>(
() => _carrito.Agregar(producto, precio));
Assert.Contains(mensajeEsperado, ex.Message);
}
[Theory]
[InlineData(0, 100)] // 0% descuento = total completo
[InlineData(10, 90)] // 10% descuento
[InlineData(50, 50)] // 50% descuento
[InlineData(100, 0)] // 100% descuento = gratis
public void TotalConDescuento_PorcentajeValido_AplicaDescuento(
decimal porcentaje, decimal esperado)
{
_carrito.Agregar("Producto", 100m);
Assert.Equal(esperado, _carrito.TotalConDescuento(porcentaje));
}
[Theory]
[InlineData(-1)]
[InlineData(101)]
public void TotalConDescuento_PorcentajeFueraDeRango_LanzaExcepcion(
decimal porcentaje)
{
_carrito.Agregar("Producto", 100m);
Assert.Throws<ArgumentOutOfRangeException>(
() => _carrito.TotalConDescuento(porcentaje));
}
[Fact]
public void Vaciar_ConItems_DejaCarritoVacio()
{
_carrito.Agregar("Producto", 10m, 5);
_carrito.Vaciar();
Assert.Equal(0, _carrito.TotalItems);
Assert.Equal(0m, _carrito.Total);
}
}
9. Ejecutar tests
# Ejecutar todos los tests
dotnet test
# Con detalle
dotnet test --verbosity normal
# Solo una clase
dotnet test --filter "FullyQualifiedName~CarritoServiceTests"
# Solo Fact (sin Theory)
dotnet test --filter "Category!=Theory"
# Generar reporte
dotnet test --logger "trx;LogFileName=resultados.trx"
10. Ejercicios
Ejercicio 1
Escribe los mismos tests de ValidadorPassword del módulo anterior pero usando xUnit en lugar de NUnit. Compara la sintaxis.
Ejercicio 2
Crea una clase StringUtils con métodos: Invertir(string), ContarVocales(string), EsPalindromo(string). Escribe tests con [Theory] y [InlineData].
Ejercicio 3
Crea una clase ListaOrdenada<T> que mantiene los elementos siempre ordenados al insertar. Escribe tests que verifiquen: inserción mantiene orden, elementos duplicados, lista vacía.
Resumen
| Concepto | NUnit | xUnit |
|---|---|---|
| Test simple | [Test] | [Fact] |
| Parametrizado | [TestCase] | [Theory] + [InlineData] |
| Datos externos | [TestCaseSource] | [MemberData] / [ClassData] |
| Setup | [SetUp] | Constructor |
| Teardown | [TearDown] | IDisposable |
| Assert igual | Assert.That(x, Is.EqualTo(y)) | Assert.Equal(y, x) |
| Assert excepción | Assert.That(() => ..., Throws...) | Assert.Throws<T>() |
| Compartir recurso | - | IClassFixture<T> |