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.WriteLine en tests de xUnit. Usa ITestOutputHelper para 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>