En esta página

Testing en .NET con xUnit y Moq

14 min lectura TextoCap. 5 — Producción

¿Por qué escribir tests?

Los tests automáticos te permiten:

  1. Detectar regresiones — los cambios no rompen funcionalidad existente
  2. Diseñar mejor — el código testeable es naturalmente más modular
  3. Documentar comportamiento — los tests son especificaciones ejecutables
  4. Refactorizar con confianza — si los tests pasan, el comportamiento es correcto

En .NET, el framework de testing más popular es xUnit, moderno y bien integrado con el ecosistema.

Instalar dependencias de testing

# Crear proyecto de tests
dotnet new xunit -n MiApi.Tests

# Referenciar el proyecto principal
dotnet add reference ../MiApi/MiApi.csproj

# Paquetes esenciales
dotnet add package Moq                           # mocking
dotnet add package FluentAssertions               # asserts legibles
dotnet add package Microsoft.AspNetCore.Mvc.Testing  # tests de integración
dotnet add package Microsoft.EntityFrameworkCore.InMemory  # BD en memoria

xUnit: [Fact] y [Theory]

// [Fact] — un test único, sin parámetros
[Fact]
public void Sumar_DosNumeros_RetornaSuma()
{
    // Arrange
    var calculadora = new Calculadora();

    // Act
    int resultado = calculadora.Sumar(3, 4);

    // Assert
    Assert.Equal(7, resultado);
}

// [Theory] con [InlineData] — mismo test, múltiples datos
[Theory]
[InlineData(3,  4,  7)]
[InlineData(0,  0,  0)]
[InlineData(-1, 1,  0)]
[InlineData(int.MaxValue, 1, int.MinValue)] // overflow
public void Sumar_DiversosInputs_RetornaSumaCorrecta(int a, int b, int esperado)
{
    var calc = new Calculadora();
    int resultado = calc.Sumar(a, b);
    Assert.Equal(esperado, resultado);
}

// [Theory] con datos complejos usando [MemberData]
public static IEnumerable<object[]> DatosPrecio =>
    new List<object[]>
    {
        new object[] { 100m,  0.10m, 90m  }, // 10% descuento
        new object[] { 200m,  0.20m, 160m }, // 20% descuento
        new object[] { 50m,   0m,    50m  }, // sin descuento
    };

[Theory]
[MemberData(nameof(DatosPrecio))]
public void AplicarDescuento_RetornaPrecioCorrectoReducido(
    decimal precio, decimal descuento, decimal esperado)
{
    var result = PrecioService.AplicarDescuento(precio, descuento);
    Assert.Equal(esperado, result);
}

Mocking con Moq

Moq permite crear objetos falsos que simulan el comportamiento de dependencias:

// Setup — qué devuelve el mock cuando se le llama
var repo = new Mock<IProductoRepository>();

// Retornar un valor específico
repo.Setup(r => r.ObtenerPorIdAsync(1))
    .ReturnsAsync(new Producto { Id = 1, Nombre = "Laptop" });

// Retornar null para cualquier ID que no sea 1
repo.Setup(r => r.ObtenerPorIdAsync(It.IsAny<int>()))
    .ReturnsAsync((Producto?)null);

// Simular excepciones
repo.Setup(r => r.EliminarAsync(-1))
    .ThrowsAsync(new ArgumentException("ID inválido"));

// Callback — ejecutar código adicional
repo.Setup(r => r.CrearAsync(It.IsAny<Producto>()))
    .Callback<Producto>(p => p.Id = 99)
    .ReturnsAsync(new Producto { Id = 99 });

// Verify — verificar que se llamó como esperado
repo.Verify(r => r.ObtenerPorIdAsync(1), Times.Once);
repo.Verify(r => r.EliminarAsync(It.IsAny<int>()), Times.Never);
repo.VerifyAll(); // verifica todos los Setups con verificación habilitada

Patrón Arrange-Act-Assert (AAA)

public class ServicioPedidoTests
{
    [Fact]
    public async Task ProcesarPedido_ActualizaStockYEnviaEmail()
    {
        // ── ARRANGE — preparar datos y mocks ────────────────
        var repoMock  = new Mock<IProductoRepository>();
        var emailMock = new Mock<IEmailService>();

        var producto = new Producto { Id = 1, Nombre = "Laptop", Stock = 5 };
        repoMock.Setup(r => r.ObtenerPorIdAsync(1)).ReturnsAsync(producto);
        repoMock.Setup(r => r.ActualizarAsync(producto)).ReturnsAsync(producto);

        var servicio = new ServicioPedido(repoMock.Object, emailMock.Object);
        var pedido   = new Pedido { ProductoId = 1, Cantidad = 2, EmailCliente = "[email protected]" };

        // ── ACT — ejecutar la acción bajo test ───────────────
        var resultado = await servicio.ProcesarAsync(pedido);

        // ── ASSERT — verificar el resultado ──────────────────
        resultado.Should().BeTrue();
        producto.Stock.Should().Be(3); // 5 - 2
        emailMock.Verify(e => e.EnviarConfirmacionAsync("[email protected]"), Times.Once);
    }

    [Fact]
    public async Task ProcesarPedido_LanzaExcepcion_CuandoStockInsuficiente()
    {
        // Arrange
        var repoMock = new Mock<IProductoRepository>();
        repoMock.Setup(r => r.ObtenerPorIdAsync(1))
                .ReturnsAsync(new Producto { Id = 1, Stock = 1 });

        var servicio = new ServicioPedido(repoMock.Object, Mock.Of<IEmailService>());
        var pedido   = new Pedido { ProductoId = 1, Cantidad = 5 };

        // Act & Assert
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => servicio.ProcesarAsync(pedido));
    }
}

Fixtures y constructores compartidos

// Fixture compartida entre tests (se crea una sola vez)
public class DatabaseFixture : IDisposable
{
    public TiendaDbContext Db { get; }

    public DatabaseFixture()
    {
        var options = new DbContextOptionsBuilder<TiendaDbContext>()
            .UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}")
            .Options;
        Db = new TiendaDbContext(options);
        Db.Database.EnsureCreated();

        // Datos de prueba
        Db.Categorias.Add(new Categoria { Id = 1, Nombre = "Electrónica" });
        Db.SaveChanges();
    }

    public void Dispose() => Db.Dispose();
}

// Usar la fixture
public class ProductoTests : IClassFixture<DatabaseFixture>
{
    private readonly TiendaDbContext _db;

    public ProductoTests(DatabaseFixture fixture)
        => _db = fixture.Db;

    [Fact]
    public async Task Agregar_GuardaEnDB()
    {
        _db.Productos.Add(new Producto
        {
            Nombre = "Monitor", Precio = 399m, Stock = 10, CategoriaId = 1
        });
        await _db.SaveChangesAsync();

        var count = await _db.Productos.CountAsync();
        count.Should().Be(1);
    }
}

Tests de integración con WebApplicationFactory

// Fixture para el servidor de tests
public class ApiFixture : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // Reemplazar BD real con InMemory
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<TiendaDbContext>));
            if (descriptor != null)
                services.Remove(descriptor);

            services.AddDbContext<TiendaDbContext>(opts =>
                opts.UseInMemoryDatabase("IntegrationTestDb"));
        });

        builder.UseEnvironment("Testing");
    }
}

public class ProductosIntegrationTests : IClassFixture<ApiFixture>
{
    private readonly HttpClient _http;

    public ProductosIntegrationTests(ApiFixture fixture)
        => _http = fixture.CreateClient();

    [Fact]
    public async Task GET_Returns200_WhenProductosExist()
    {
        // Seeding previo (o el endpoint devuelve lista vacía con 200)
        var response = await _http.GetAsync("/api/v1/productos");

        response.StatusCode.Should().Be(HttpStatusCode.OK);
        var body = await response.Content.ReadFromJsonAsync<List<ProductoDto>>();
        body.Should().NotBeNull();
    }

    [Fact]
    public async Task POST_InvalidProducto_Returns400()
    {
        var invalido = new { Nombre = "", Precio = -10m }; // no pasa validación
        var response = await _http.PostAsJsonAsync("/api/v1/productos", invalido);

        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
    }
}

Ejecutar los tests

# Ejecutar todos los tests
dotnet test

# Con reporte detallado
dotnet test --verbosity normal

# Filtrar tests por nombre
dotnet test --filter "Sumar"

# Con cobertura de código
dotnet test --collect:"XPlat Code Coverage"
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:"**/coverage.cobertura.xml" -targetdir:coverage-report

# Hot reload (re-ejecuta al detectar cambios)
dotnet watch test

Práctica

  1. Tests unitarios: Escribe 3 tests [Fact] y 1 [Theory] para una clase CalculadoraDescuento con métodos AplicarDescuento(decimal precio, decimal pct) y EsValido(decimal precio).
  2. Mock de repositorio: Usa Moq para mockear un IProductoRepository y probar un ProductoService que lance NotFoundException cuando el producto no existe.
  3. Test de integración: Crea un test con WebApplicationFactory que pruebe POST /api/productos con datos válidos y verifique el 201 Created y el header Location.

En la siguiente lección pondrás en práctica todo lo aprendido construyendo una API de catálogo de productos completa con EF Core, JWT, middleware y tests.

Nombra tus tests con el patrón Metodo_Escenario_ResultadoEsperado
Un buen nombre de test describe exactamente lo que prueba. Usa el patrón: ObtenerPorId_CuandoIdNoExiste_RetornaNull o Crear_ConNombreVacio_LanzaArgumentException. Esto hace que los reportes de fallo sean auto-explicativos sin necesidad de leer el código del test.
FluentAssertions para asserts legibles
FluentAssertions (paquete NuGet) ofrece una sintaxis mucho más legible que el Assert clásico: resultado.Should().Be(5) en vez de Assert.Equal(5, resultado). También da mensajes de error más descriptivos cuando falla. Úsalo en todos tus proyectos.
Tests aislados con Moq y bases de datos InMemory
Los tests unitarios NO deben tocar la base de datos real. Usa Moq para mockear repositorios y servicios. Los tests de integración SÍ pueden usar una base de datos, pero usa InMemory o SQLite en modo memoria para no contaminar datos de desarrollo/producción.
using Xunit;
using Moq;
using FluentAssertions;

// ── Tests con [Fact] y [Theory] ──────────────────────
public class ProductoServiceTests
{
    private readonly Mock<IProductoRepository> _repoMock;
    private readonly ProductoService _sut; // System Under Test

    public ProductoServiceTests()
    {
        _repoMock = new Mock<IProductoRepository>();
        _sut      = new ProductoService(_repoMock.Object);
    }

    [Fact]
    public async Task ObtenerPorId_RetornaProducto_CuandoExiste()
    {
        // Arrange
        var productoEsperado = new Producto { Id = 1, Nombre = "Laptop", Precio = 999m };
        _repoMock.Setup(r => r.ObtenerPorIdAsync(1))
                 .ReturnsAsync(productoEsperado);

        // Act
        var resultado = await _sut.ObtenerPorIdAsync(1);

        // Assert
        resultado.Should().NotBeNull();
        resultado!.Id.Should().Be(1);
        resultado.Nombre.Should().Be("Laptop");
        _repoMock.Verify(r => r.ObtenerPorIdAsync(1), Times.Once);
    }

    [Fact]
    public async Task ObtenerPorId_RetornaNull_CuandoNoExiste()
    {
        _repoMock.Setup(r => r.ObtenerPorIdAsync(99))
                 .ReturnsAsync((Producto?)null);

        var resultado = await _sut.ObtenerPorIdAsync(99);

        resultado.Should().BeNull();
    }

    // [Theory] — mismo test con diferentes datos
    [Theory]
    [InlineData("",    false)]
    [InlineData("  ",  false)]
    [InlineData("A",   false)]  // muy corto
    [InlineData("Laptop Pro", true)]
    [InlineData("X",   false)]
    public void NombreEsValido_RetornaResultadoCorrecto(string nombre, bool esperado)
    {
        var valido = ProductoService.NombreEsValido(nombre);
        valido.Should().Be(esperado);
    }
}