En esta página
Testing en .NET con xUnit y Moq
¿Por qué escribir tests?
Los tests automáticos te permiten:
- Detectar regresiones — los cambios no rompen funcionalidad existente
- Diseñar mejor — el código testeable es naturalmente más modular
- Documentar comportamiento — los tests son especificaciones ejecutables
- 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 memoriaxUnit: [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 habilitadaPatró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 testPráctica
- Tests unitarios: Escribe 3 tests [Fact] y 1 [Theory] para una clase
CalculadoraDescuentocon métodosAplicarDescuento(decimal precio, decimal pct)yEsValido(decimal precio). - Mock de repositorio: Usa Moq para mockear un
IProductoRepositoryy probar unProductoServiceque lanceNotFoundExceptioncuando el producto no existe. - Test de integración: Crea un test con
WebApplicationFactoryque pruebePOST /api/productoscon datos válidos y verifique el 201 Created y el headerLocation.
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);
}
}
Inicia sesión para guardar tu progreso