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