Módulo 05: Pruebas Unitarias con NUnit
Objetivos del módulo
- Comprender qué son las pruebas unitarias y cuándo usarlas
- Configurar un proyecto de tests con NUnit 4.x
- Dominar las aserciones (Assert.That) y atributos
- Aplicar patrones AAA (Arrange-Act-Assert)
- Organizar y ejecutar suites de tests
1. ¿Qué son las pruebas unitarias?
Una prueba unitaria verifica que una unidad de código (método, función, clase) funciona correctamente de forma aislada.
Características
| Propiedad | Descripción |
|---|---|
| Rápidas | Milisegundos por test |
| Aisladas | No dependen de BD, red, archivos |
| Repetibles | Mismo resultado cada vez |
| Autovalidadas | Pass/Fail automático, sin revisar logs |
| Oportunas | Se escriben junto con el código |
¿Qué probar?
| Sí probar | No probar |
|---|---|
| Lógica de negocio | Getters/setters triviales |
| Cálculos y transformaciones | Código del framework |
| Validaciones | Código de terceros |
| Casos límite y errores | Constructores vacíos |
| Algoritmos complejos | Interfaz de usuario directamente |
2. Configurar el proyecto
Crear solución con proyecto de tests
# Crear la solución
dotnet new sln -n MiAppTests
# Proyecto principal
dotnet new classlib -n MiApp
dotnet sln add MiApp
# Proyecto de tests NUnit
dotnet new nunit -n MiApp.Tests
dotnet sln add MiApp.Tests
# Referencia del proyecto de tests al principal
dotnet add MiApp.Tests reference MiApp
Estructura resultante
MiAppTests/
├── MiApp/
│ ├── MiApp.csproj
│ └── Calculadora.cs
├── MiApp.Tests/
│ ├── MiApp.Tests.csproj ← Tiene NUnit como dependencia
│ └── CalculadoraTests.cs
└── MiAppTests.sln
Paquetes NUnit (ya incluidos con dotnet new nunit)
<!-- MiApp.Tests.csproj -->
<ItemGroup>
<PackageReference Include="nunit" Version="4.*" />
<PackageReference Include="NUnit3TestAdapter" Version="4.*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
</ItemGroup>
3. Primer test
Código a probar
// MiApp/Calculadora.cs
namespace MiApp;
public class Calculadora
{
public int Sumar(int a, int b) => a + b;
public int Restar(int a, int b) => a - b;
public double Dividir(double a, double b)
{
if (b == 0)
throw new DivideByZeroException("No se puede dividir entre cero");
return a / b;
}
}
Test
// MiApp.Tests/CalculadoraTests.cs
using MiApp;
namespace MiApp.Tests;
[TestFixture] // Marca la clase como contenedor de tests
public class CalculadoraTests
{
private Calculadora _calc; // Sistema bajo prueba (SUT)
[SetUp] // Se ejecuta ANTES de cada test
public void Setup()
{
_calc = new Calculadora();
}
[Test] // Marca el método como un test
public void Sumar_DosPositivos_RetornaSuma()
{
// Arrange (Preparar)
int a = 2, b = 3;
// Act (Actuar)
int resultado = _calc.Sumar(a, b);
// Assert (Verificar)
Assert.That(resultado, Is.EqualTo(5));
}
}
Ejecutar
dotnet test
Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1
4. Patrón AAA (Arrange-Act-Assert)
Cada test sigue 3 fases claramente separadas:
[Test]
public void NombreMetodo_Escenario_ResultadoEsperado()
{
// Arrange — Preparar datos y objetos
var servicio = new MiServicio();
string entrada = "dato";
// Act — Ejecutar la acción
var resultado = servicio.Procesar(entrada);
// Assert — Verificar el resultado
Assert.That(resultado, Is.Not.Null);
}
💡 Consejo: El nombre del test sigue el patrón:
MetodoBajoPrueba_Escenario_ResultadoEsperado. Así se lee como una especificación.
5. Aserciones (Assert.That)
NUnit 4.x usa el constraint model con Assert.That():
Igualdad
Assert.That(resultado, Is.EqualTo(5)); // resultado == 5
Assert.That(texto, Is.EqualTo("hola")); // texto == "hola"
Assert.That(precio, Is.EqualTo(9.99).Within(0.01)); // double con tolerancia
Comparación
Assert.That(edad, Is.GreaterThan(17)); // edad > 17
Assert.That(edad, Is.LessThanOrEqualTo(65)); // edad <= 65
Assert.That(valor, Is.InRange(1, 100)); // 1 <= valor <= 100
Nulos y booleanos
Assert.That(objeto, Is.Null); // objeto es null
Assert.That(objeto, Is.Not.Null); // objeto no es null
Assert.That(condicion, Is.True); // true
Assert.That(condicion, Is.False); // false
Strings
Assert.That(texto, Does.Contain("mundo")); // contiene
Assert.That(texto, Does.StartWith("Hola")); // empieza con
Assert.That(texto, Does.EndWith("!")); // termina con
Assert.That(texto, Is.Empty); // cadena vacía
Assert.That(texto, Does.Match(@"\d+")); // regex
Assert.That(texto, Is.EqualTo("HOLA").IgnoreCase); // ignora mayúsculas
Colecciones
Assert.That(lista, Has.Count.EqualTo(3)); // tiene 3 elementos
Assert.That(lista, Does.Contain(5)); // contiene el 5
Assert.That(lista, Is.Empty); // está vacía
Assert.That(lista, Is.Ordered); // está ordenada
Assert.That(lista, Is.All.GreaterThan(0)); // todos > 0
Assert.That(lista, Is.Unique); // sin duplicados
Assert.That(lista, Has.Exactly(2).GreaterThan(10));// exactamente 2 > 10
Excepciones
Assert.That(() => _calc.Dividir(10, 0),
Throws.TypeOf<DivideByZeroException>());
Assert.That(() => _calc.Dividir(10, 0),
Throws.TypeOf<DivideByZeroException>()
.With.Message.Contains("cero"));
Tipos
Assert.That(animal, Is.TypeOf<Perro>()); // tipo exacto
Assert.That(animal, Is.InstanceOf<Animal>()); // tipo o derivado
6. Atributos de NUnit
Atributos principales
| Atributo | Uso |
|---|---|
[TestFixture] | Marca una clase como contenedora de tests |
[Test] | Marca un método como test |
[SetUp] | Se ejecuta antes de cada test |
[TearDown] | Se ejecuta después de cada test |
[OneTimeSetUp] | Se ejecuta una vez antes de todos los tests de la clase |
[OneTimeTearDown] | Se ejecuta una vez después de todos los tests |
[Ignore("razón")] | Ignora el test temporalmente |
[Category("nombre")] | Categoriza tests para filtrar |
Ciclo de vida
[TestFixture]
public class CicloDeVidaTests
{
[OneTimeSetUp]
public void InicioGlobal()
{
Console.WriteLine("1. OneTimeSetUp");
}
[SetUp]
public void AntesDeCadaTest()
{
Console.WriteLine(" 2. SetUp");
}
[Test]
public void TestA()
{
Console.WriteLine(" 3. TestA ejecutándose");
}
[Test]
public void TestB()
{
Console.WriteLine(" 3. TestB ejecutándose");
}
[TearDown]
public void DespuesDeCadaTest()
{
Console.WriteLine(" 4. TearDown");
}
[OneTimeTearDown]
public void FinGlobal()
{
Console.WriteLine("5. OneTimeTearDown");
}
}
Salida:
1. OneTimeSetUp
2. SetUp
3. TestA ejecutándose
4. TearDown
2. SetUp
3. TestB ejecutándose
4. TearDown
5. OneTimeTearDown
7. Tests parametrizados
[TestCase] — Datos en línea
[TestCase(2, 3, 5)] // 2 + 3 = 5
[TestCase(0, 0, 0)] // 0 + 0 = 0
[TestCase(-1, 1, 0)] // -1 + 1 = 0
[TestCase(100, 200, 300)] // 100 + 200 = 300
public void Sumar_VariosValores_RetornaSumaCorrecta(int a, int b, int esperado)
{
int resultado = _calc.Sumar(a, b);
Assert.That(resultado, Is.EqualTo(esperado));
}
[TestCaseSource] — Datos desde propiedad o método
private static IEnumerable<TestCaseData> CasosDivision()
{
yield return new TestCaseData(10.0, 2.0, 5.0).SetName("10/2=5");
yield return new TestCaseData(9.0, 3.0, 3.0).SetName("9/3=3");
yield return new TestCaseData(7.0, 2.0, 3.5).SetName("7/2=3.5");
}
[TestCaseSource(nameof(CasosDivision))]
public void Dividir_Casos_RetornaCociente(double a, double b, double esperado)
{
double resultado = _calc.Dividir(a, b);
Assert.That(resultado, Is.EqualTo(esperado).Within(0.001));
}
[Values] y [Range]
[Test]
public void EsPositivo_NumerosPositivos_RetornaTrue(
[Values(1, 5, 100, 999)] int numero)
{
Assert.That(numero > 0, Is.True);
}
[Test]
public void Cuadrado_Rango_RetornaValorCorrecto(
[Range(1, 5)] int numero)
{
int resultado = numero * numero;
Assert.That(resultado, Is.EqualTo(numero * numero));
}
8. Ejemplo completo: Validador de contraseñas
Código a probar
// MiApp/ValidadorPassword.cs
namespace MiApp;
public class ValidadorPassword
{
public (bool esValida, List<string> errores) Validar(string password)
{
var errores = new List<string>();
if (string.IsNullOrEmpty(password))
{
errores.Add("La contraseña no puede estar vacía");
return (false, errores);
}
if (password.Length < 8)
errores.Add("Mínimo 8 caracteres");
if (password.Length > 30)
errores.Add("Máximo 30 caracteres");
if (!password.Any(char.IsUpper))
errores.Add("Debe contener al menos una mayúscula");
if (!password.Any(char.IsLower))
errores.Add("Debe contener al menos una minúscula");
if (!password.Any(char.IsDigit))
errores.Add("Debe contener al menos un número");
return (errores.Count == 0, errores);
}
}
Tests completos
// MiApp.Tests/ValidadorPasswordTests.cs
using MiApp;
namespace MiApp.Tests;
[TestFixture]
public class ValidadorPasswordTests
{
private ValidadorPassword _validador;
[SetUp]
public void Setup()
{
_validador = new ValidadorPassword();
}
// --- Tests positivos ---
[TestCase("Password1")]
[TestCase("Abcdefg1")]
[TestCase("MiClave123ConMuchos")]
public void Validar_PasswordValida_RetornaTrue(string password)
{
var (esValida, errores) = _validador.Validar(password);
Assert.That(esValida, Is.True);
Assert.That(errores, Is.Empty);
}
// --- Tests negativos ---
[Test]
public void Validar_Null_RetornaError()
{
var (esValida, errores) = _validador.Validar(null!);
Assert.That(esValida, Is.False);
Assert.That(errores, Does.Contain("La contraseña no puede estar vacía"));
}
[Test]
public void Validar_Vacia_RetornaError()
{
var (esValida, errores) = _validador.Validar("");
Assert.That(esValida, Is.False);
Assert.That(errores, Does.Contain("La contraseña no puede estar vacía"));
}
[Test]
public void Validar_MuyCorta_RetornaError()
{
var (esValida, errores) = _validador.Validar("Ab1");
Assert.That(esValida, Is.False);
Assert.That(errores, Does.Contain("Mínimo 8 caracteres"));
}
[Test]
public void Validar_SinMayusculas_RetornaError()
{
var (esValida, errores) = _validador.Validar("password1");
Assert.That(esValida, Is.False);
Assert.That(errores, Does.Contain("Debe contener al menos una mayúscula"));
}
[Test]
public void Validar_SinMinusculas_RetornaError()
{
var (esValida, errores) = _validador.Validar("PASSWORD1");
Assert.That(esValida, Is.False);
Assert.That(errores, Does.Contain("Debe contener al menos una minúscula"));
}
[Test]
public void Validar_SinNumeros_RetornaError()
{
var (esValida, errores) = _validador.Validar("Password");
Assert.That(esValida, Is.False);
Assert.That(errores, Does.Contain("Debe contener al menos un número"));
}
// --- Tests de valores límite ---
[Test]
public void Validar_Exacto8Caracteres_EsValida()
{
var (esValida, _) = _validador.Validar("Abcdef1x"); // 8 chars
Assert.That(esValida, Is.True);
}
[Test]
public void Validar_7Caracteres_EsInvalida()
{
var (esValida, _) = _validador.Validar("Abcde1x"); // 7 chars
Assert.That(esValida, Is.False);
}
// --- Test de múltiples errores ---
[Test]
public void Validar_MultiplesFallos_RetornaTodosLosErrores()
{
var (esValida, errores) = _validador.Validar("abc");
Assert.That(esValida, Is.False);
Assert.That(errores, Has.Count.GreaterThanOrEqualTo(2));
}
}
Ejecutar
dotnet test --verbosity normal
9. Buenas prácticas
| Práctica | Ejemplo |
|---|---|
| Un assert por test (idealmente) | Cada test verifica una cosa |
| Nombres descriptivos | Validar_PasswordVacia_RetornaFalse |
| Patrón AAA | Arrange-Act-Assert claramente separados |
| No testear implementación | Testear comportamiento, no cómo lo hace |
| Tests independientes | No compartir estado entre tests |
| Fast | Evitar operaciones lentas (red, BD, archivos) |
| No usar lógica | Sin if/for en los tests |
10. Ejercicios
Ejercicio 1
Crea una clase Conversor con métodos CelsiusAFahrenheit(double c) y FahrenheitACelsius(double f). Escribe al menos 6 tests parametrizados.
Ejercicio 2
Crea una clase ValidadorEmail que valide el formato de un email. Escribe tests para: email válido, sin @, sin dominio, con espacios, vacío, con múltiples @.
Ejercicio 3
Crea una clase CuentaBancaria con métodos Depositar, Retirar, ObtenerSaldo. Escribe tests para: depósito positivo, retiro válido, retiro mayor que saldo (excepción), saldo inicial.
Resumen
| Concepto | Descripción |
|---|---|
| Prueba unitaria | Verifica una unidad de código de forma aislada |
| NUnit | Framework de testing para .NET |
| AAA | Arrange → Act → Assert |
| Assert.That | Modelo de aserciones con constraints |
| [Test] | Marca un método como test |
| [TestCase] | Test parametrizado con datos en línea |
| [SetUp] | Se ejecuta antes de cada test |