Cómo escribir tests unitarios en .NET Core con xUnit


Los tests unitarios son fundamentales para mantener la calidad del código en cualquier proyecto de software. En el ecosistema .NET Core, xUnit se ha convertido en una de las herramientas más populares y potentes para implementar pruebas unitarias. En este artículo, exploraremos desde los conceptos básicos hasta técnicas avanzadas para crear tests efectivos.
🎯 ¿Por qué elegir xUnit?
xUnit ofrece varias ventajas sobre otras frameworks de testing:
- Sintaxis limpia y expresiva: Facilita la escritura y lectura de tests
- Paralelización automática: Ejecuta tests en paralelo por defecto
- Extensibilidad: Permite crear atributos personalizados y extensiones
- Integración nativa: Funciona perfectamente con .NET Core y herramientas de CI/CD
🚀 Configuración inicial del proyecto
Para comenzar, necesitamos crear un proyecto de pruebas y configurar las dependencias necesarias:
dotnet new xunit -n MiProyecto.Tests
cd MiProyecto.Tests
dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Moq
El archivo .csproj
debería verse así:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" />
<PackageReference Include="Moq" Version="4.20.69" />
</ItemGroup>
</Project>
📝 Anatomía de un test unitario
Un test unitario bien estructurado sigue el patrón AAA (Arrange-Act-Assert):
[Fact]
public void CalcularDescuento_ConMontoValido_DebeRetornarDescuentoCorrecto()
{
// Arrange - Preparación
var calculadora = new CalculadoraDescuentos();
var montoOriginal = 100m;
var porcentajeDescuento = 10;
// Act - Acción
var resultado = calculadora.CalcularDescuento(montoOriginal, porcentajeDescuento);
// Assert - Verificación
Assert.Equal(10m, resultado);
}
🔧 Atributos fundamentales de xUnit
[Fact]
- Para tests simples
[Fact]
public void Usuario_ConEmailValido_DebeCrearseCorrectamente()
{
var email = "usuario@ejemplo.com";
var usuario = new Usuario(email);
Assert.Equal(email, usuario.Email);
Assert.True(usuario.EsValido);
}
[Theory]
- Para tests parametrizados
[Theory]
[InlineData(0, 0)]
[InlineData(1, 1)]
[InlineData(5, 120)]
[InlineData(10, 3628800)]
public void CalcularFactorial_ConDiferentesValores_DebeRetornarResultadoCorrecto(int numero, long esperado)
{
var calculadora = new CalculadoraMatematica();
var resultado = calculadora.CalcularFactorial(numero);
Assert.Equal(esperado, resultado);
}
🎭 Trabajando con mocks usando Moq
Los mocks son esenciales para aislar el código bajo prueba:
public class ServicioUsuarios
{
private readonly IRepositorioUsuarios _repositorio;
private readonly IServicioEmail _servicioEmail;
public ServicioUsuarios(IRepositorioUsuarios repositorio, IServicioEmail servicioEmail)
{
_repositorio = repositorio;
_servicioEmail = servicioEmail;
}
public async Task<bool> CrearUsuarioAsync(Usuario usuario)
{
await _repositorio.GuardarAsync(usuario);
await _servicioEmail.EnviarBienvenidaAsync(usuario.Email);
return true;
}
}
[Fact]
public async Task CrearUsuario_ConDatosValidos_DebeGuardarYEnviarEmail()
{
// Arrange
var mockRepositorio = new Mock<IRepositorioUsuarios>();
var mockServicioEmail = new Mock<IServicioEmail>();
var servicio = new ServicioUsuarios(mockRepositorio.Object, mockServicioEmail.Object);
var usuario = new Usuario("test@ejemplo.com");
// Act
var resultado = await servicio.CrearUsuarioAsync(usuario);
// Assert
Assert.True(resultado);
mockRepositorio.Verify(r => r.GuardarAsync(usuario), Times.Once);
mockServicioEmail.Verify(s => s.EnviarBienvenidaAsync(usuario.Email), Times.Once);
}
🏗️ Fixtures y datos compartidos
Para compartir datos entre tests, podemos usar fixtures:
public class DatabaseFixture : IDisposable
{
public string ConnectionString { get; }
public DatabaseFixture()
{
ConnectionString = "Server=localhost;Database=TestDB;";
// Inicializar base de datos de prueba
}
public void Dispose()
{
// Limpiar recursos
}
}
public class UsuarioRepositoryTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _fixture;
public UsuarioRepositoryTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task ObtenerUsuario_ConIdExistente_DebeRetornarUsuario()
{
// Usar _fixture.ConnectionString para las pruebas
}
}
⚠️ Manejo de excepciones en tests
[Fact]
public void DividirPorCero_DebeArrojarExcepcion()
{
var calculadora = new Calculadora();
var excepcion = Assert.Throws<DivideByZeroException>(() =>
calculadora.Dividir(10, 0));
Assert.Equal("No se puede dividir por cero", excepcion.Message);
}
[Fact]
public async Task ObtenerUsuario_ConIdInexistente_DebeArrojarExcepcion()
{
var mockRepositorio = new Mock<IRepositorioUsuarios>();
mockRepositorio.Setup(r => r.ObtenerPorIdAsync(999))
.ThrowsAsync(new UsuarioNoEncontradoException("Usuario no encontrado"));
var servicio = new ServicioUsuarios(mockRepositorio.Object, null);
await Assert.ThrowsAsync<UsuarioNoEncontradoException>(() =>
servicio.ObtenerUsuarioAsync(999));
}
🔍 Técnicas avanzadas de testing
Testing con datos complejos
public class UsuarioTestData : IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
{
yield return new object[] { new Usuario("juan@test.com", "Juan", 25), true };
yield return new object[] { new Usuario("", "María", 30), false };
yield return new object[] { new Usuario("ana@test.com", "", 22), false };
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
[Theory]
[ClassData(typeof(UsuarioTestData))]
public void ValidarUsuario_ConDiferentesDatos_DebeRetornarResultadoCorrecto(Usuario usuario, bool esperado)
{
var validator = new ValidadorUsuario();
var resultado = validator.EsValido(usuario);
Assert.Equal(esperado, resultado);
}
Testing con archivos y recursos
[Fact]
public async Task ProcesarArchivoCsv_ConDatosValidos_DebeImportarCorrectamente()
{
// Arrange
var contenidoCsv = "Nombre,Email,Edad\nJuan,juan@test.com,25\nMaría,maria@test.com,30";
var stream = new MemoryStream(Encoding.UTF8.GetBytes(contenidoCsv));
var procesador = new ProcesadorCsv();
// Act
var usuarios = await procesador.ImportarUsuariosAsync(stream);
// Assert
Assert.Equal(2, usuarios.Count);
Assert.Equal("Juan", usuarios[0].Nombre);
Assert.Equal("María", usuarios[1].Nombre);
}
📊 Mejores prácticas para tests efectivos
Nomenclatura clara y descriptiva
// ❌ Mal
[Fact]
public void Test1() { }
// ✅ Bien
[Fact]
public void CalcularImpuesto_ConMontoPositivo_DebeRetornarImpuestoCorrecto() { }
Un test, una responsabilidad
// ❌ Mal - Testea múltiples comportamientos
[Fact]
public void ProcesarPedido_DebeValidarYGuardarYEnviarEmail() { }
// ✅ Bien - Tests separados
[Fact]
public void ProcesarPedido_ConDatosValidos_DebeGuardarEnBaseDatos() { }
[Fact]
public void ProcesarPedido_ConDatosValidos_DebeEnviarEmailConfirmacion() { }
Usar builders para objetos complejos
public class UsuarioBuilder
{
private string _email = "test@ejemplo.com";
private string _nombre = "Usuario Test";
private int _edad = 25;
public UsuarioBuilder ConEmail(string email)
{
_email = email;
return this;
}
public UsuarioBuilder ConNombre(string nombre)
{
_nombre = nombre;
return this;
}
public Usuario Build() => new Usuario(_email, _nombre, _edad);
}
[Fact]
public void ValidarUsuario_ConEmailInvalido_DebeRetornarFalse()
{
var usuario = new UsuarioBuilder()
.ConEmail("email-invalido")
.Build();
var resultado = new ValidadorUsuario().EsValido(usuario);
Assert.False(resultado);
}
🚀 Integración con CI/CD
Para ejecutar tests en pipelines de CI/CD, puedes usar estos comandos:
# Ejecutar todos los tests
dotnet test
# Ejecutar tests con reporte de cobertura
dotnet test --collect:"XPlat Code Coverage"
# Ejecutar tests con filtros
dotnet test --filter "Category=Integration"
🎨 Configuración de salida y logging
public class TestsConLogging
{
private readonly ITestOutputHelper _output;
public TestsConLogging(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public void Test_ConLogging_DebeEscribirEnSalida()
{
_output.WriteLine("Iniciando test...");
var resultado = EjecutarLogicaCompleja();
_output.WriteLine($"Resultado obtenido: {resultado}");
Assert.True(resultado);
}
}
🎯 Conclusión
Los tests unitarios con xUnit en .NET Core son una herramienta poderosa para mantener la calidad del código. La clave está en escribir tests claros, mantenibles y que realmente aporten valor al proyecto. Recuerda que un buen test no solo verifica que el código funciona, sino que también documenta el comportamiento esperado del sistema.
La inversión de tiempo en escribir tests de calidad se traduce en menos bugs en producción, mayor confianza al hacer cambios, y un código más robusto y mantenible a largo plazo.
¿Te ha resultado útil este artículo? ¡Comparte tus experiencias y dudas en los comentarios!
Subscribe to my newsletter
Read articles from Iván Peinado directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Iván Peinado
Iván Peinado
20 años de experiencia como desarrollador en tecnologías .NET. Desde que comencé mi aventura profesional no he dejado de interesarme por todo lo que rodea a esta tecnología. Me considero un apasionado de mi trabajo, intentando siempre aprender, evolucionar y conseguir unas metas y objetivos. La tecnología cambia constantemente y por ello es necesario tener una base consolidada y seguir adquiriendo nuevos y mayores conocimientos que hagan de nuestro trabajo más fácil. Intento siempre, aprender nuevas herramientas y funcionalidades relacionadas con la tecnología .NET que me ayude a seguir avanzando en mi carrera profesional y aportando nuevas ideas en los proyectos en los que participo.