Modulo 21: Testing con NUnit

Objetivos del modulo

  • Entender que es una prueba automatizada y por que protege el codigo.
  • Crear una solucion de pruebas NUnit desde Visual Studio Community.
  • Desarrollar una biblioteca Tienda.Core y probarla paso a paso.
  • Escribir pruebas unitarias, parametrizadas, asincronas y de integracion.
  • Usar Assert.That, constraints, fixtures, categorias y ciclos de vida.
  • Organizar pruebas mantenibles en carpetas Unitarias, Integracion y TestData.

1. Iniciacion

Concepto: NUnit es un framework de testing para .NET. Permite escribir metodos que comprueban automaticamente si una clase se comporta como esperamos.

Una prueba automatizada suele seguir el patron AAA:

Fase Significado Ejemplo
Arrange Preparar datos y dependencias Crear una calculadora
Act Ejecutar la accion Aplicar un descuento
Assert Verificar el resultado Comprobar el precio final
[Test]
public void AplicarDescuento_ConPrecio100YDescuento10_Devuelve90()
{
    CalculadoraDescuentos calculadora = new();

    decimal resultado = calculadora.AplicarDescuento(100m, 10m);

    Assert.That(resultado, Is.EqualTo(90m));
}

En este modulo construiremos el proyecto completo Tienda.NUnit, igual que el proyecto de referencia del curso.


2. Proyecto que vamos a construir

La solucion final tendra esta estructura:

Tienda.NUnit/
  Tienda.NUnit.sln
  src/
    Tienda.Core/
      CalculadoraDescuentos.cs
      Carrito.cs
      PedidoService.cs
      Producto.cs
      ProductoRepository.cs
      ProductoService.cs
      ReservaService.cs
  tests/
    Tienda.Core.Tests/
      Unitarias/
        CalculadoraDescuentosTests.cs
        CarritoTests.cs
        ProductoServiceTests.cs
        PedidoServiceTests.cs
        ReservaServiceTests.cs
        AtributosTests.cs
      Integracion/
        ProductoRepositoryTests.cs
      TestData/
        ProductoBuilder.cs
      Properties/
        AssemblyInfo.cs

El proyecto Tienda.Core contiene codigo de negocio. El proyecto Tienda.Core.Tests contiene las pruebas.


3. Crear la solucion en Visual Studio Community

  1. Abre Visual Studio Community.
  2. Selecciona Crear un proyecto nuevo.
  3. Busca Blank Solution o Solucion en blanco.
  4. Nombre: Tienda.NUnit.
  5. Crea una carpeta src y otra carpeta tests desde el Explorador de soluciones.
  6. En src, agrega un proyecto Class Library llamado Tienda.Core.
  7. En tests, agrega un proyecto NUnit Test Project llamado Tienda.Core.Tests.
  8. En Tienda.Core.Tests, agrega una referencia de proyecto a Tienda.Core.
  9. En Tienda.Core, instala el paquete NuGet Microsoft.Data.Sqlite.
  10. Comprueba que Tienda.Core.Tests contiene estos paquetes:
    • Microsoft.NET.Test.Sdk
    • NUnit
    • NUnit3TestAdapter
    • NUnit.Analyzers
    • coverlet.collector
  11. Crea en el proyecto de pruebas las carpetas Unitarias, Integracion, TestData y Properties.

Tambien puedes crear la solucion por terminal:

dotnet new sln -n Tienda.NUnit -f sln
dotnet new classlib -n Tienda.Core -o src/Tienda.Core --framework net10.0
dotnet new nunit -n Tienda.Core.Tests -o tests/Tienda.Core.Tests --framework net10.0
dotnet sln Tienda.NUnit.sln add src/Tienda.Core/Tienda.Core.csproj tests/Tienda.Core.Tests/Tienda.Core.Tests.csproj
dotnet add tests/Tienda.Core.Tests/Tienda.Core.Tests.csproj reference src/Tienda.Core/Tienda.Core.csproj
dotnet add src/Tienda.Core/Tienda.Core.csproj package Microsoft.Data.Sqlite

4. Primera clase de negocio

Crea src/Tienda.Core/CalculadoraDescuentos.cs:

namespace Tienda.Core;

public class CalculadoraDescuentos
{
    public decimal AplicarDescuento(decimal precio, decimal porcentaje)
    {
        if (precio < 0)
            throw new ArgumentOutOfRangeException(nameof(precio));

        if (porcentaje is < 0 or > 100)
            throw new ArgumentOutOfRangeException(nameof(porcentaje));

        return precio - precio * porcentaje / 100;
    }
}

Reglas que debe cumplir:

  • 100 con 10% devuelve 90.
  • Un precio negativo lanza excepcion.
  • Un porcentaje menor que 0 o mayor que 100 lanza excepcion.

5. Primeras pruebas NUnit

Crea tests/Tienda.Core.Tests/Unitarias/CalculadoraDescuentosTests.cs:

using Tienda.Core;

namespace Tienda.Core.Tests.Unitarias;

[TestFixture]
[Category("Unitarias")]
public class CalculadoraDescuentosTests
{
    [Test]
    public void AplicarDescuento_ConPrecio100YDescuento10_Devuelve90()
    {
        CalculadoraDescuentos calculadora = new();

        decimal resultado = calculadora.AplicarDescuento(100m, 10m);

        Assert.That(resultado, Is.EqualTo(90m));
    }

    [Test]
    public void AplicarDescuento_ConPrecioNegativo_LanzaExcepcion()
    {
        CalculadoraDescuentos calculadora = new();

        Assert.That(
            () => calculadora.AplicarDescuento(-1m, 10m),
            Throws.TypeOf<ArgumentOutOfRangeException>());
    }
}

Ejecuta las pruebas desde Test > Test Explorer o con:

dotnet test

Que hace [TestFixture]

[TestFixture] marca una clase como clase de pruebas NUnit. Dentro de esa clase escribimos metodos marcados con [Test], [TestCase] o [TestCaseSource].

En versiones actuales de NUnit muchas veces la clase se detecta aunque no pongas [TestFixture], siempre que tenga metodos [Test]. Aun asi, en este curso lo usaremos porque ayuda a leer el codigo:

[TestFixture]
public class CalculadoraDescuentosTests
{
    [Test]
    public void AplicarDescuento_ConDatosValidos_DevuelveResultado()
    {
        Assert.Pass();
    }
}

Piensalo asi:

Elemento Para que sirve
[TestFixture] Indica que la clase contiene pruebas
[Test] Indica que el metodo es una prueba
[SetUp] Metodo que se ejecuta antes de cada prueba
[TearDown] Metodo que se ejecuta despues de cada prueba
[Category] Permite clasificar pruebas

Ejemplo mental:

CalculadoraDescuentosTests        -> conjunto de pruebas
  AplicarDescuento_...            -> prueba individual
  AplicarDescuento_...            -> prueba individual

Nota: si en algun sitio ves escrito TextFixture, es un error tipografico. El atributo correcto de NUnit es TestFixture.


6. Pruebas parametrizadas

Agrega a CalculadoraDescuentosTests una prueba con [TestCase]:

[TestCase(100, 0, 100)]
[TestCase(100, 10, 90)]
[TestCase(200, 25, 150)]
[TestCase(50, 50, 25)]
public void AplicarDescuento_CasosValidos_DevuelveResultado(
    decimal precio,
    decimal descuento,
    decimal esperado)
{
    CalculadoraDescuentos calculadora = new();

    decimal resultado = calculadora.AplicarDescuento(precio, descuento);

    Assert.That(resultado, Is.EqualTo(esperado));
}

Despues usa datos externos con [TestCaseSource]:

public static IEnumerable<TestCaseData> CasosDeDescuento()
{
    yield return new TestCaseData(100m, 15m, 85m).SetName("Descuento del 15%");
    yield return new TestCaseData(80m, 25m, 60m).SetName("Descuento del 25%");
}

[TestCaseSource(nameof(CasosDeDescuento))]
public void AplicarDescuento_ConFuenteDeDatos(decimal precio, decimal descuento, decimal esperado)
{
    CalculadoraDescuentos calculadora = new();

    Assert.That(calculadora.AplicarDescuento(precio, descuento), Is.EqualTo(esperado));
}

7. Modelo y carrito

Crea Producto.cs:

namespace Tienda.Core;

public class Producto
{
    public int Id { get; set; }
    public string Nombre { get; set; } = string.Empty;
    public string Categoria { get; set; } = string.Empty;
    public decimal Precio { get; set; }
    public int Stock { get; set; }
    public DateTime FechaCreacion { get; set; } = DateTime.UtcNow;
}

Crea Carrito.cs:

namespace Tienda.Core;

public class Carrito
{
    private readonly List<Producto> _productos = [];

    public int TotalItems => _productos.Count;
    public bool EstaVacio => _productos.Count == 0;
    public IReadOnlyCollection<Producto> Productos => _productos.AsReadOnly();

    public void Agregar(Producto producto)
    {
        ArgumentNullException.ThrowIfNull(producto);
        _productos.Add(producto);
    }

    public void Limpiar()
    {
        _productos.Clear();
    }
}

8. Ciclo de vida de una prueba

Crea CarritoTests.cs:

using Tienda.Core;

namespace Tienda.Core.Tests.Unitarias;

[TestFixture]
[Category("Unitarias")]
public class CarritoTests
{
    private Carrito _carrito = null!;

    [OneTimeSetUp]
    public void AntesDeTodas()
    {
        TestContext.Out.WriteLine("Se ejecuta una vez antes de la clase");
    }

    [SetUp]
    public void AntesDeCadaTest()
    {
        _carrito = new Carrito();
    }

    [TearDown]
    public void DespuesDeCadaTest()
    {
        _carrito.Limpiar();
    }

    [Test]
    public void Carrito_Nuevo_EstaVacio()
    {
        Assert.That(_carrito.TotalItems, Is.EqualTo(0));
    }
}
Atributo Cuando se ejecuta
[OneTimeSetUp] Una vez antes de todos los tests de la clase
[SetUp] Antes de cada test
[TearDown] Despues de cada test
[OneTimeTearDown] Una vez despues de todos los tests

9. Pruebas asincronas

Hasta ahora las pruebas han llamado a metodos normales. En aplicaciones reales muchas operaciones son asincronas:

  • Consultar una base de datos.
  • Llamar a una API.
  • Leer o escribir ficheros.
  • Enviar emails.
  • Esperar una operacion externa.

En C# esas operaciones suelen devolver Task o Task<T>.

Tipo de metodo Que devuelve Ejemplo
Sincrono Un valor directo decimal Calcular()
Asincrono sin resultado Task Task EnviarAsync()
Asincrono con resultado Task<T> Task<decimal> ObtenerPrecioAsync()

NUnit permite que una prueba sea asincrona simplemente devolviendo Task y usando await.

9.1 Crear un servicio asincrono

Crea src/Tienda.Core/ProductoService.cs:

namespace Tienda.Core;

public class ProductoService
{
    public async Task<decimal> ObtenerPrecioAsync(int productoId)
    {
        if (productoId <= 0)
            throw new ArgumentOutOfRangeException(nameof(productoId));

        await Task.Delay(50);

        return productoId == 1 ? 89.99m : 0m;
    }
}

Este codigo simula una consulta externa con Task.Delay(50). En un proyecto real, en ese lugar podria haber una consulta a base de datos o una llamada HTTP.

9.2 Probar el caso correcto

Crea tests/Tienda.Core.Tests/Unitarias/ProductoServiceTests.cs:

using Tienda.Core;

namespace Tienda.Core.Tests.Unitarias;

[TestFixture]
[Category("Unitarias")]
public class ProductoServiceTests
{
    [Test]
    public async Task ObtenerPrecioAsync_ProductoExistente_DevuelvePrecio()
    {
        ProductoService service = new();

        decimal precio = await service.ObtenerPrecioAsync(1);

        Assert.That(precio, Is.EqualTo(89.99m));
    }
}

Puntos importantes:

  • La prueba devuelve async Task, no void.
  • Dentro de la prueba usamos await.
  • NUnit espera a que termine la tarea antes de evaluar el resultado.

9.3 Probar una excepcion asincrona

Cuando el metodo asincrono lanza una excepcion, debemos pasar a NUnit una funcion asincrona:

[Test]
public void ObtenerAsync_IdInvalido_LanzaExcepcion()
{
    ProductoService service = new();

    Assert.That(
        async () => await service.ObtenerPrecioAsync(-1),
        Throws.TypeOf<ArgumentOutOfRangeException>());
}

Tambien puedes escribirlo asi si quieres inspeccionar la excepcion:

[Test]
public async Task ObtenerAsync_IdInvalido_IndicaParametroProductoId()
{
    ProductoService service = new();

    ArgumentOutOfRangeException ex = Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () =>
        await service.ObtenerPrecioAsync(-1));

    Assert.That(ex!.ParamName, Is.EqualTo("productoId"));
}

9.4 Errores habituales con async

Evita esto:

[Test]
public async void PruebaIncorrecta()
{
    ProductoService service = new();

    await service.ObtenerPrecioAsync(1);
}

Una prueba async void puede ocultar errores. La forma correcta es:

[Test]
public async Task PruebaCorrecta()
{
    ProductoService service = new();

    decimal precio = await service.ObtenerPrecioAsync(1);

    Assert.That(precio, Is.EqualTo(89.99m));
}

9.5 Ejemplo con cancelacion

La cancelacion es habitual en servicios asincronos. Puedes ampliar el ejemplo:

public class BuscadorProductos
{
    public async Task<List<Producto>> BuscarAsync(string texto, CancellationToken cancellationToken)
    {
        await Task.Delay(100, cancellationToken);

        return
        [
            new Producto { Nombre = $"Resultado para {texto}" }
        ];
    }
}

Prueba:

[Test]
public void BuscarAsync_Cancelado_LanzaTaskCanceledException()
{
    BuscadorProductos buscador = new();
    using CancellationTokenSource cts = new();
    cts.Cancel();

    Assert.That(
        async () => await buscador.BuscarAsync("teclado", cts.Token),
        Throws.TypeOf<TaskCanceledException>());
}

10. Fakes manuales

Una prueba unitaria debe probar una pieza pequena de codigo. El problema aparece cuando esa pieza depende de algo externo:

  • Un servidor de correo.
  • Una base de datos.
  • Una API.
  • El reloj del sistema.
  • Un sistema de ficheros.

Para no depender de elementos externos usamos dobles de prueba.

Tipo Que hace
Dummy Se pasa como argumento pero no se usa
Stub Devuelve datos preparados
Fake Implementacion simple que funciona para tests
Spy Guarda informacion para comprobar que algo ocurrio
Mock Objeto configurado con expectativas, normalmente con libreria

En este modulo haremos un fake manual. Es la forma mas sencilla de entender el concepto.

10.1 Crear una dependencia mediante interfaz

Crea src/Tienda.Core/PedidoService.cs:

namespace Tienda.Core;

public interface IEmailSender
{
    Task EnviarAsync(string destino, string asunto, string cuerpo);
}

public class PedidoService
{
    private readonly IEmailSender _emailSender;

    public PedidoService(IEmailSender emailSender)
    {
        _emailSender = emailSender;
    }

    public async Task ConfirmarPedidoAsync(string email)
    {
        if (string.IsNullOrWhiteSpace(email))
            throw new ArgumentException("El email es obligatorio", nameof(email));

        await _emailSender.EnviarAsync(
            email,
            "Pedido confirmado",
            "Gracias por tu compra");
    }
}

PedidoService no sabe como se envia realmente el email. Solo sabe que existe algo que cumple IEmailSender.

Ventajas:

  • En produccion puedes usar un email real.
  • En pruebas puedes usar un fake.
  • El servicio queda desacoplado.

10.2 Crear un fake en el proyecto de pruebas

Crea el fake dentro de PedidoServiceTests o en una carpeta TestDoubles:

private sealed class FakeEmailSender : IEmailSender
{
    public List<string> Destinatarios { get; } = [];
    public List<string> Asuntos { get; } = [];
    public List<string> Cuerpos { get; } = [];

    public Task EnviarAsync(string destino, string asunto, string cuerpo)
    {
        Destinatarios.Add(destino);
        Asuntos.Add(asunto);
        Cuerpos.Add(cuerpo);

        return Task.CompletedTask;
    }
}

Este fake no envia correos. Solo guarda lo que habria enviado.

10.3 Probar que se envia el email correcto

[Test]
public async Task ConfirmarPedidoAsync_EnviaEmailAlCliente()
{
    FakeEmailSender fake = new();
    PedidoService service = new(fake);

    await service.ConfirmarPedidoAsync("ana@ejemplo.com");

    Assert.Multiple(() =>
    {
        Assert.That(fake.Destinatarios, Contains.Item("ana@ejemplo.com"));
        Assert.That(fake.Asuntos, Contains.Item("Pedido confirmado"));
        Assert.That(fake.Cuerpos.Single(), Does.Contain("Gracias"));
    });
}

10.4 Probar errores sin enviar email

[Test]
public void ConfirmarPedidoAsync_EmailVacio_LanzaExcepcion()
{
    FakeEmailSender fake = new();
    PedidoService service = new(fake);

    Assert.That(
        async () => await service.ConfirmarPedidoAsync(""),
        Throws.TypeOf<ArgumentException>());

    Assert.That(fake.Destinatarios, Is.Empty);
}

Observa el ultimo assert: ademas de comprobar la excepcion, comprobamos que no se intento enviar ningun email.

10.5 Cuanto debe saber el test

Un test no deberia comprobar detalles internos privados, pero si puede comprobar comportamiento observable:

  • Correcto: “se pidio enviar email a ana@ejemplo.com”.
  • Correcto: “el asunto contiene Pedido confirmado”.
  • Incorrecto: “el servicio llamo a una variable privada concreta”.

11. Builder de datos de prueba

Cuando un objeto tiene muchas propiedades, las pruebas se llenan de preparacion repetida:

Producto producto = new()
{
    Nombre = "Teclado",
    Categoria = "Perifericos",
    Precio = 89.99m,
    Stock = 10,
    FechaCreacion = DateTime.UtcNow
};

Si repites esto en 20 pruebas, el codigo se vuelve pesado. Un builder de datos de prueba permite crear objetos validos con valores por defecto y cambiar solo lo importante para cada test.

11.1 Crear el builder

Crea tests/Tienda.Core.Tests/TestData/ProductoBuilder.cs:

using Tienda.Core;

namespace Tienda.Core.Tests.TestData;

public class ProductoBuilder
{
    private string _nombre = "Teclado";
    private string _categoria = "Perifericos";
    private decimal _precio = 89.99m;
    private int _stock = 10;
    private DateTime _fechaCreacion = new(2026, 1, 1);

    public ProductoBuilder ConNombre(string nombre)
    {
        _nombre = nombre;
        return this;
    }

    public ProductoBuilder ConCategoria(string categoria)
    {
        _categoria = categoria;
        return this;
    }

    public ProductoBuilder ConPrecio(decimal precio)
    {
        _precio = precio;
        return this;
    }

    public ProductoBuilder ConStock(int stock)
    {
        _stock = stock;
        return this;
    }

    public ProductoBuilder SinStock()
    {
        _stock = 0;
        return this;
    }

    public Producto Build()
    {
        return new Producto
        {
            Nombre = _nombre,
            Categoria = _categoria,
            Precio = _precio,
            Stock = _stock,
            FechaCreacion = _fechaCreacion
        };
    }
}

Detalles importantes:

  • Los metodos devuelven this para poder encadenarlos.
  • Los valores por defecto deben representar un producto valido.
  • Cada prueba modifica solo el dato que necesita.

11.2 Crear el servicio a probar

Crea src/Tienda.Core/ReservaService.cs:

namespace Tienda.Core;

public class ReservaService
{
    public void Reservar(Producto producto)
    {
        ArgumentNullException.ThrowIfNull(producto);

        if (producto.Stock <= 0)
            throw new InvalidOperationException("No se puede reservar un producto sin stock.");

        producto.Stock--;
    }
}

11.3 Probar caso de error

using Tienda.Core;
using Tienda.Core.Tests.TestData;

namespace Tienda.Core.Tests.Unitarias;

[TestFixture]
[Category("Unitarias")]
public class ReservaServiceTests
{
    [Test]
    public void Reservar_ProductoSinStock_LanzaExcepcion()
    {
        Producto producto = new ProductoBuilder().SinStock().Build();
        ReservaService service = new();

        Assert.That(
            () => service.Reservar(producto),
            Throws.TypeOf<InvalidOperationException>());
    }
}

11.4 Probar caso correcto

[Test]
public void Reservar_ProductoConStock_RestaUnaUnidad()
{
    Producto producto = new ProductoBuilder()
        .ConStock(3)
        .Build();
    ReservaService service = new();

    service.Reservar(producto);

    Assert.That(producto.Stock, Is.EqualTo(2));
}

11.5 Por que mejora la prueba

Sin builder:

Producto producto = new()
{
    Nombre = "Teclado",
    Categoria = "Perifericos",
    Precio = 89.99m,
    Stock = 0,
    FechaCreacion = DateTime.UtcNow
};

Con builder:

Producto producto = new ProductoBuilder().SinStock().Build();

La segunda version expresa mejor la intencion de la prueba: “producto sin stock”.


12. Integracion con SQLite en memoria

Una prueba unitaria comprueba una clase aislada. Una prueba de integracion comprueba que varias piezas funcionan juntas.

En este apartado probaremos:

  • Codigo C# del repositorio.
  • Conexion SQLite.
  • Creacion de tabla.
  • Insercion de datos.
  • Lectura de datos.
  • Mapeo entre filas SQL y objetos Producto.

12.1 Instalar el paquete necesario

En el proyecto Tienda.Core, instala Microsoft.Data.Sqlite.

Desde Visual Studio:

  1. Clic derecho en Tienda.Core.
  2. Manage NuGet Packages.
  3. Buscar Microsoft.Data.Sqlite.
  4. Instalar.

Desde terminal:

dotnet add src/Tienda.Core/Tienda.Core.csproj package Microsoft.Data.Sqlite

El .csproj de Tienda.Core debe contener algo parecido a:

<ItemGroup>
  <PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
</ItemGroup>

12.2 Que significa SQLite en memoria

SQLite normalmente guarda datos en un fichero:

Data Source=tienda.db

Para pruebas podemos usar una base en memoria:

Data Source=:memory:

Eso significa:

  • No se crea ningun fichero.
  • La base es rapida.
  • La base esta vacia al empezar.
  • La base desaparece al cerrar la conexion.

Punto clave: con SQLite en memoria, la base de datos vive mientras la conexion permanece abierta. Por eso abrimos la conexion en [SetUp] y la cerramos en [TearDown].

12.3 Crear el repositorio

Crea src/Tienda.Core/ProductoRepository.cs:

using Microsoft.Data.Sqlite;

namespace Tienda.Core;

public class ProductoRepository
{
    private readonly SqliteConnection _connection;

    public ProductoRepository(SqliteConnection connection)
    {
        _connection = connection;
    }

    public async Task InicializarAsync()
    {
        SqliteCommand command = _connection.CreateCommand();
        command.CommandText = """
            CREATE TABLE IF NOT EXISTS Productos (
                Id INTEGER PRIMARY KEY AUTOINCREMENT,
                Nombre TEXT NOT NULL,
                Categoria TEXT NOT NULL,
                Precio TEXT NOT NULL,
                Stock INTEGER NOT NULL,
                FechaCreacion TEXT NOT NULL
            );
            """;

        await command.ExecuteNonQueryAsync();
    }

    public async Task<int> CrearAsync(Producto producto)
    {
        SqliteCommand command = _connection.CreateCommand();
        command.CommandText = """
            INSERT INTO Productos (Nombre, Categoria, Precio, Stock, FechaCreacion)
            VALUES ($nombre, $categoria, $precio, $stock, $fechaCreacion);
            SELECT last_insert_rowid();
            """;
        command.Parameters.AddWithValue("$nombre", producto.Nombre);
        command.Parameters.AddWithValue("$categoria", producto.Categoria);
        command.Parameters.AddWithValue("$precio", producto.Precio.ToString(System.Globalization.CultureInfo.InvariantCulture));
        command.Parameters.AddWithValue("$stock", producto.Stock);
        command.Parameters.AddWithValue("$fechaCreacion", producto.FechaCreacion.ToString("O"));

        object? result = await command.ExecuteScalarAsync();

        return Convert.ToInt32(result);
    }

    public async Task<Producto?> ObtenerPorIdAsync(int id)
    {
        SqliteCommand command = _connection.CreateCommand();
        command.CommandText = """
            SELECT Id, Nombre, Categoria, Precio, Stock, FechaCreacion
            FROM Productos
            WHERE Id = $id;
            """;
        command.Parameters.AddWithValue("$id", id);

        await using SqliteDataReader reader = await command.ExecuteReaderAsync();

        if (!await reader.ReadAsync())
            return null;

        return new Producto
        {
            Id = reader.GetInt32(0),
            Nombre = reader.GetString(1),
            Categoria = reader.GetString(2),
            Precio = decimal.Parse(reader.GetString(3), System.Globalization.CultureInfo.InvariantCulture),
            Stock = reader.GetInt32(4),
            FechaCreacion = DateTime.Parse(
                reader.GetString(5),
                null,
                System.Globalization.DateTimeStyles.RoundtripKind)
        };
    }
}

12.4 Explicacion del codigo de repositorio

Linea o concepto Explicacion
SqliteConnection Representa la conexion abierta con SQLite
CreateCommand() Crea una sentencia SQL
CommandText Contiene el SQL que se va a ejecutar
$nombre, $precio Parametros SQL para evitar concatenar strings
ExecuteNonQueryAsync() Ejecuta SQL que no devuelve filas
ExecuteScalarAsync() Devuelve un unico valor, como el id insertado
ExecuteReaderAsync() Lee filas devueltas por un SELECT
await using Libera el reader al terminar

No concatenes valores en SQL:

// Mal
command.CommandText = $"SELECT * FROM Productos WHERE Nombre = '{nombre}'";

Usa parametros:

// Bien
command.CommandText = "SELECT * FROM Productos WHERE Nombre = $nombre";
command.Parameters.AddWithValue("$nombre", nombre);

12.5 Crear la prueba de integracion

Crea tests/Tienda.Core.Tests/Integracion/ProductoRepositoryTests.cs:

using Microsoft.Data.Sqlite;
using Tienda.Core;

namespace Tienda.Core.Tests.Integracion;

[TestFixture]
[Category("Integracion")]
public class ProductoRepositoryTests
{
    private SqliteConnection _connection = null!;
    private ProductoRepository _repository = null!;

    [SetUp]
    public async Task SetUp()
    {
        _connection = new SqliteConnection("Data Source=:memory:");
        await _connection.OpenAsync();

        _repository = new ProductoRepository(_connection);
        await _repository.InicializarAsync();
    }

    [TearDown]
    public async Task TearDown()
    {
        await _connection.DisposeAsync();
    }

    [Test]
    public async Task CrearAsync_GuardaProducto()
    {
        int id = await _repository.CrearAsync(new Producto
        {
            Nombre = "Monitor",
            Categoria = "Pantallas",
            Precio = 199.99m,
            Stock = 5,
            FechaCreacion = DateTime.UtcNow
        });

        Producto? producto = await _repository.ObtenerPorIdAsync(id);

        Assert.Multiple(() =>
        {
            Assert.That(producto, Is.Not.Null);
            Assert.That(producto!.Nombre, Is.EqualTo("Monitor"));
            Assert.That(producto.Categoria, Is.EqualTo("Pantallas"));
            Assert.That(producto.Stock, Is.EqualTo(5));
        });
    }
}

12.6 Que hace cada parte de la prueba

Parte Responsabilidad
[SetUp] Crear una base nueva para cada test
OpenAsync() Abrir la conexion; sin esto la base no existe
InicializarAsync() Crear la tabla Productos
CrearAsync() Insertar un producto
ObtenerPorIdAsync() Leerlo desde SQLite
[TearDown] Cerrar la conexion y destruir la base en memoria

Cada prueba tiene su propia base de datos. Esto evita que una prueba dependa de datos creados por otra.

12.7 Probar producto inexistente

Agrega esta prueba:

[Test]
public async Task ObtenerPorIdAsync_IdInexistente_DevuelveNull()
{
    Producto? producto = await _repository.ObtenerPorIdAsync(999);

    Assert.That(producto, Is.Null);
}

12.8 Probar varios productos

Si agregas ObtenerTodosAsync al repositorio, puedes probar:

[Test]
public async Task ObtenerTodosAsync_ConProductos_DevuelveLista()
{
    await _repository.CrearAsync(new Producto
    {
        Nombre = "Teclado",
        Categoria = "Perifericos",
        Precio = 89.99m,
        Stock = 10,
        FechaCreacion = DateTime.UtcNow
    });

    await _repository.CrearAsync(new Producto
    {
        Nombre = "Monitor",
        Categoria = "Pantallas",
        Precio = 199.99m,
        Stock = 5,
        FechaCreacion = DateTime.UtcNow
    });

    List<Producto> productos = await _repository.ObtenerTodosAsync();

    Assert.Multiple(() =>
    {
        Assert.That(productos, Has.Count.EqualTo(2));
        Assert.That(productos.Select(p => p.Nombre), Does.Contain("Teclado"));
        Assert.That(productos.Select(p => p.Nombre), Does.Contain("Monitor"));
    });
}

12.9 Ejecutar solo pruebas de integracion

dotnet test --filter "Category=Integracion"

En Visual Studio Community puedes filtrar por categoria desde el Explorador de pruebas.


13. Categorias, ignorados, repeticion y paralelismo

NUnit permite controlar como se organizan y ejecutan las pruebas mediante atributos.

En este apartado veremos:

  • Category: clasificar pruebas.
  • Ignore: desactivar temporalmente una prueba.
  • Repeat: repetir una prueba.
  • Parallelizable: ejecutar pruebas en paralelo.
  • LevelOfParallelism: limitar cuantos tests se ejecutan a la vez.

13.1 Categorias

Una categoria es una etiqueta. Sirve para filtrar pruebas.

[Test]
[Category("Unitarias")]
public void TestUnitarioRapido()
{
    Assert.Pass();
}

[Test]
[Category("Integracion")]
public void TestDeIntegracion()
{
    Assert.Pass();
}

Ejecutar solo unitarias:

dotnet test --filter "Category=Unitarias"

Ejecutar solo integracion:

dotnet test --filter "Category=Integracion"

Uso recomendado:

Categoria Uso
Unitarias Pruebas rapidas sin dependencias externas
Integracion Pruebas con base de datos, API, ficheros o infraestructura
Lentas Pruebas que tardan mas de lo normal
E2E Pruebas de navegador, si las hubiera

13.2 Poner categoria a una clase completa

Si todas las pruebas de una clase son unitarias:

[TestFixture]
[Category("Unitarias")]
public class CalculadoraDescuentosTests
{
    [Test]
    public void AplicarDescuento_CasoValido_DevuelveResultado()
    {
        Assert.Pass();
    }
}

Asi no tienes que repetir [Category("Unitarias")] en cada metodo.

13.3 Ignorar una prueba

[Ignore] desactiva una prueba temporalmente.

[Test]
[Ignore("Pendiente de corregir la regla de negocio")]
public void TestTemporalmenteIgnorado()
{
    Assert.Fail();
}

Cuando ejecutes los tests, NUnit mostrara la prueba como omitida.

Buenas practicas:

  • Usa siempre un motivo claro.
  • No dejes pruebas ignoradas indefinidamente.
  • Si una prueba ya no tiene sentido, eliminala.
  • Si una prueba falla por un bug real, registra el bug antes de ignorarla.

13.4 Repetir una prueba

[Repeat] ejecuta la misma prueba varias veces:

[Test]
[Repeat(3)]
public void TestRepetido()
{
    Assert.That(Guid.NewGuid(), Is.Not.EqualTo(Guid.Empty));
}

Para que sirve:

  • Detectar fallos intermitentes.
  • Probar codigo con valores aleatorios.
  • Repetir escenarios donde puede aparecer una condicion de carrera.

No debe usarse para ocultar problemas. Si una prueba falla a veces, hay que investigar la causa.

13.5 Paralelismo

Ejecutar pruebas en paralelo reduce tiempo, pero puede provocar errores si las pruebas comparten estado.

Ejemplos de estado compartido peligroso:

  • Un fichero con el mismo nombre.
  • Una base de datos compartida.
  • Variables static modificables.
  • Un servicio externo con datos comunes.

Ejemplos de pruebas seguras para paralelizar:

  • Calculos puros.
  • Validadores sin estado.
  • Servicios con fakes independientes.
  • Repositorios con base de datos en memoria por prueba.

13.6 Activar paralelismo para el ensamblado

Crea tests/Tienda.Core.Tests/Properties/AssemblyInfo.cs:

using NUnit.Framework;

[assembly: Parallelizable(ParallelScope.Fixtures)]
[assembly: LevelOfParallelism(4)]

Explicacion:

Atributo Significado
Parallelizable(ParallelScope.Fixtures) NUnit puede ejecutar clases de prueba en paralelo
LevelOfParallelism(4) Como maximo 4 trabajos en paralelo

Con Fixtures, NUnit puede ejecutar clases diferentes a la vez, pero no necesariamente metodos de la misma clase al mismo tiempo.

13.7 Paralelismo por clase

Tambien puedes marcar una clase concreta:

[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CalculadoraTests
{
    [Test]
    public void Test1() => Assert.Pass();

    [Test]
    public void Test2() => Assert.Pass();
}

ParallelScope.All permite paralelizar mas agresivamente, incluidos metodos dentro de la misma clase. Usalo solo si estas seguro de que no comparten estado.

13.8 Evitar paralelismo en una clase

Si una clase no debe ejecutarse en paralelo:

[TestFixture]
[NonParallelizable]
public class ProductoRepositoryTests
{
    // Pruebas que comparten un recurso delicado
}

En nuestro ejemplo con SQLite en memoria cada test crea su propia conexion, asi que es bastante seguro. Si todas las pruebas compartieran la misma conexion o el mismo fichero, convendria desactivar paralelismo.

13.9 Clase completa de ejemplo

Crea tests/Tienda.Core.Tests/Unitarias/AtributosTests.cs:

namespace Tienda.Core.Tests.Unitarias;

[TestFixture]
public class AtributosTests
{
    [Test]
    [Category("Unitarias")]
    public void TestUnitarioRapido()
    {
        Assert.Pass();
    }

    [Test]
    [Category("Integracion")]
    public void TestDeIntegracion()
    {
        Assert.Pass();
    }

    [Test]
    [Ignore("Ejemplo didactico de prueba ignorada")]
    public void TestTemporalmenteIgnorado()
    {
        Assert.Fail();
    }

    [Test]
    [Repeat(3)]
    public void TestRepetido()
    {
        Assert.That(Guid.NewGuid(), Is.Not.EqualTo(Guid.Empty));
    }
}

13.10 Comandos utiles

dotnet test
dotnet test --filter "Category=Unitarias"
dotnet test --filter "Category=Integracion"
dotnet test --filter "FullyQualifiedName~ProductoRepository"

En Visual Studio Community:

  1. Abre Test > Test Explorer.
  2. Agrupa por Traits o filtra por texto.
  3. Busca Unitarias o Integracion.
  4. Observa que las pruebas con [Ignore] aparecen como omitidas.

14. Plan docente recomendado

Este modulo puede ocupar entre 3 y 4 horas dentro del bloque de testing. Una distribucion razonable:

Tiempo Contenido Resultado esperado
20 min Que es una prueba automatizada y patron AAA El alumno entiende Arrange, Act, Assert
35 min Crear solucion, proyectos y primera prueba CalculadoraDescuentosTests en verde
40 min Parametrizacion y constraints Pruebas con [TestCase] y [TestCaseSource]
35 min Ciclo de vida y CarritoTests Uso de [SetUp], [TearDown] y Assert.Multiple
30 min Pruebas asincronas y fakes ProductoServiceTests y PedidoServiceTests
45 min Integracion con SQLite ProductoRepositoryTests en memoria
25 min Categorias, filtros y cobertura Ejecucion selectiva desde Visual Studio y terminal
30 min Ejercicio guiado El alumno amplia carrito o impuestos

Sugerencia de ritmo: no copies todo el codigo de golpe. Escribe primero una prueba que falle, ejecutala, implementa el minimo codigo y vuelve a ejecutar. Ese ciclo es la parte importante.


15. Ruta de construccion guiada

Puedes plantear el modulo como una serie de etapas. Cada etapa deja el proyecto en un punto estable y demostrable.

Etapa 1: solucion minima

Objetivo:

  • Crear Tienda.Core.
  • Crear Tienda.Core.Tests.
  • Referenciar produccion desde tests.
  • Ejecutar una prueba vacia para validar el entorno.

Checklist:

  • La solucion abre en Visual Studio Community.
  • dotnet test funciona.
  • El Explorador de pruebas detecta el proyecto.

Etapa 2: primera regla de negocio

Implementa CalculadoraDescuentos y prueba el caso feliz:

[Test]
public void AplicarDescuento_ConPrecio100YDescuento10_Devuelve90()
{
    CalculadoraDescuentos calculadora = new();

    decimal resultado = calculadora.AplicarDescuento(100m, 10m);

    Assert.That(resultado, Is.EqualTo(90m));
}

Preguntas para clase:

  • Que pasaria si el descuento fuese 0?
  • Que pasaria si fuese 100?
  • Tiene sentido un descuento negativo?
  • Tiene sentido un descuento mayor que 100?

Etapa 3: probar bordes

Agrega casos limite:

[TestCase(100, 0, 100)]
[TestCase(100, 100, 0)]
[TestCase(0, 50, 0)]
public void AplicarDescuento_ValoresLimite_DevuelveResultadoEsperado(
    decimal precio,
    decimal descuento,
    decimal esperado)
{
    CalculadoraDescuentos calculadora = new();

    decimal resultado = calculadora.AplicarDescuento(precio, descuento);

    Assert.That(resultado, Is.EqualTo(esperado));
}

Explicacion:

  • Los bordes suelen esconder errores.
  • Si una regla acepta de 0 a 100, conviene probar 0, 100 y valores intermedios.
  • Despues se prueban valores fuera de rango.

Etapa 4: probar errores

[TestCase(-1, 10, "precio")]
[TestCase(100, -1, "porcentaje")]
[TestCase(100, 101, "porcentaje")]
public void AplicarDescuento_ArgumentosInvalidos_LanzaExcepcion(
    decimal precio,
    decimal descuento,
    string parametro)
{
    CalculadoraDescuentos calculadora = new();

    ArgumentOutOfRangeException ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
        calculadora.AplicarDescuento(precio, descuento));

    Assert.That(ex!.ParamName, Is.EqualTo(parametro));
}

Este ejemplo permite comparar dos estilos:

Assert.That(
    () => calculadora.AplicarDescuento(-1m, 10m),
    Throws.TypeOf<ArgumentOutOfRangeException>());

y:

ArgumentOutOfRangeException ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
    calculadora.AplicarDescuento(-1m, 10m));

El segundo estilo es util cuando quieres inspeccionar la excepcion.

Etapa 5: evolucionar hacia objetos

Cuando aparece Producto, deja de probar solo numeros y empieza a probar objetos:

[Test]
public void Agregar_ProductoValido_QuedaEnElCarrito()
{
    Carrito carrito = new();
    Producto producto = new()
    {
        Nombre = "Teclado",
        Categoria = "Perifericos",
        Precio = 89.99m,
        Stock = 12
    };

    carrito.Agregar(producto);

    Assert.Multiple(() =>
    {
        Assert.That(carrito.TotalItems, Is.EqualTo(1));
        Assert.That(carrito.EstaVacio, Is.False);
        Assert.That(carrito.Productos, Has.Some.Matches<Producto>(p => p.Nombre == "Teclado"));
    });
}

Etapa 6: aislar datos con builders

Cuando los objetos crecen, no conviene repetir todos sus campos en cada prueba:

Producto producto = new ProductoBuilder()
    .ConNombre("Monitor")
    .Build();

Extiende el builder durante la clase:

public ProductoBuilder ConPrecio(decimal precio)
{
    _precio = precio;
    return this;
}

public ProductoBuilder ConStock(int stock)
{
    _stock = stock;
    return this;
}

Prueba resultante:

[Test]
public void Reservar_ProductoConStock_RestaUnaUnidad()
{
    Producto producto = new ProductoBuilder()
        .ConStock(3)
        .Build();
    ReservaService service = new();

    service.Reservar(producto);

    Assert.That(producto.Stock, Is.EqualTo(2));
}

16. Banco de ejemplos NUnit

Esta seccion sirve como material extra para clase. No hace falta implementarlo todo el primer dia; puedes usarlo para repasar, ampliar o proponer ejercicios.

16.1 Aserciones sobre cadenas

[Test]
public void NombreProducto_NoDebeEstarVacio()
{
    Producto producto = new() { Nombre = "Teclado mecanico" };

    Assert.Multiple(() =>
    {
        Assert.That(producto.Nombre, Is.Not.Empty);
        Assert.That(producto.Nombre, Does.Contain("Teclado"));
        Assert.That(producto.Nombre, Does.StartWith("Teclado"));
    });
}

16.2 Aserciones sobre colecciones

[Test]
public void Carrito_ConVariosProductos_ContieneCategoriasEsperadas()
{
    Carrito carrito = new();
    carrito.Agregar(new Producto { Nombre = "Teclado", Categoria = "Perifericos", Precio = 89.99m, Stock = 10 });
    carrito.Agregar(new Producto { Nombre = "Monitor", Categoria = "Pantallas", Precio = 199.99m, Stock = 5 });

    Assert.Multiple(() =>
    {
        Assert.That(carrito.Productos, Has.Count.EqualTo(2));
        Assert.That(carrito.Productos.Select(p => p.Categoria), Does.Contain("Perifericos"));
        Assert.That(carrito.Productos, Has.Some.Matches<Producto>(p => p.Precio > 100));
        Assert.That(carrito.Productos, Has.None.Matches<Producto>(p => p.Stock < 0));
    });
}

16.3 Aserciones con tolerancia

[Test]
public void CalculoConDouble_UsaTolerancia()
{
    double resultado = 0.1 + 0.2;

    Assert.That(resultado, Is.EqualTo(0.3).Within(0.000001));
}

Explicacion para clase: los double no siempre representan decimales exactos. En dinero se prefiere decimal.

16.4 Tests de validacion

Puedes crear un validador:

public class ProductoValidator
{
    public List<string> Validar(Producto producto)
    {
        List<string> errores = [];

        if (string.IsNullOrWhiteSpace(producto.Nombre))
            errores.Add("El nombre es obligatorio");

        if (producto.Precio < 0)
            errores.Add("El precio no puede ser negativo");

        if (producto.Stock < 0)
            errores.Add("El stock no puede ser negativo");

        return errores;
    }
}

Pruebas:

[Test]
public void Validar_ProductoCorrecto_NoDevuelveErrores()
{
    Producto producto = new ProductoBuilder().Build();
    ProductoValidator validator = new();

    List<string> errores = validator.Validar(producto);

    Assert.That(errores, Is.Empty);
}

[Test]
public void Validar_ProductoSinNombre_DevuelveError()
{
    Producto producto = new ProductoBuilder()
        .ConNombre("")
        .Build();
    ProductoValidator validator = new();

    List<string> errores = validator.Validar(producto);

    Assert.That(errores, Contains.Item("El nombre es obligatorio"));
}

16.5 Tests de servicios con dependencias

Codigo de produccion:

public interface IProductoRepository
{
    Task<Producto?> ObtenerAsync(int id);
}

public class CatalogoService
{
    private readonly IProductoRepository _repository;

    public CatalogoService(IProductoRepository repository)
    {
        _repository = repository;
    }

    public async Task<string> ObtenerNombreAsync(int id)
    {
        Producto? producto = await _repository.ObtenerAsync(id);

        if (producto is null)
            throw new InvalidOperationException("Producto no encontrado");

        return producto.Nombre;
    }
}

Fake:

private sealed class FakeProductoRepository : IProductoRepository
{
    public Producto? Producto { get; set; }

    public Task<Producto?> ObtenerAsync(int id)
    {
        return Task.FromResult(Producto);
    }
}

Prueba:

[Test]
public async Task ObtenerNombreAsync_ProductoExiste_DevuelveNombre()
{
    FakeProductoRepository repository = new()
    {
        Producto = new Producto { Nombre = "Monitor" }
    };
    CatalogoService service = new(repository);

    string nombre = await service.ObtenerNombreAsync(1);

    Assert.That(nombre, Is.EqualTo("Monitor"));
}

16.6 Tests de repositorio: leer varios registros

[Test]
public async Task ObtenerTodosAsync_ConProductos_DevuelveLista()
{
    await _repository.CrearAsync(new Producto
    {
        Nombre = "Teclado",
        Categoria = "Perifericos",
        Precio = 89.99m,
        Stock = 10
    });

    await _repository.CrearAsync(new Producto
    {
        Nombre = "Monitor",
        Categoria = "Pantallas",
        Precio = 199.99m,
        Stock = 5
    });

    List<Producto> productos = await _repository.ObtenerTodosAsync();

    Assert.Multiple(() =>
    {
        Assert.That(productos, Has.Count.EqualTo(2));
        Assert.That(productos.Select(p => p.Nombre), Does.Contain("Teclado"));
        Assert.That(productos.Select(p => p.Nombre), Does.Contain("Monitor"));
    });
}

16.7 Tests con cancelacion

Codigo:

public class BuscadorProductos
{
    public async Task<List<Producto>> BuscarAsync(string texto, CancellationToken cancellationToken)
    {
        await Task.Delay(100, cancellationToken);

        return
        [
            new Producto { Nombre = $"Resultado para {texto}" }
        ];
    }
}

Prueba:

[Test]
public void BuscarAsync_Cancelado_LanzaTaskCanceledException()
{
    BuscadorProductos buscador = new();
    using CancellationTokenSource cts = new();
    cts.Cancel();

    Assert.That(
        async () => await buscador.BuscarAsync("teclado", cts.Token),
        Throws.TypeOf<TaskCanceledException>());
}

16.8 Pruebas ordenadas por intencion

Evita nombres genericos:

// Poco expresivo
public void Test1()

// Mucho mejor
public void Reservar_ProductoSinStock_LanzaExcepcion()

Plantilla recomendada:

Metodo_Escenario_ResultadoEsperado

Ejemplos:

  • AplicarDescuento_PorcentajeCero_DevuelvePrecioOriginal.
  • CrearAsync_ProductoValido_GuardaProducto.
  • ConfirmarPedidoAsync_EmailValido_EnviaMensaje.
  • Reservar_ProductoSinStock_LanzaExcepcion.

17. Guion de explicacion en Visual Studio

  1. Abre la solucion original y muestra la estructura.
  2. Crea una solucion espejo vacia.
  3. Crea CalculadoraDescuentos.
  4. Escribe la primera prueba y ejecuta.
  5. Rompe intencionadamente el codigo para ver una prueba en rojo.
  6. Corrige el codigo y vuelve a verde.
  7. Agrega [TestCase].
  8. Agrega Carrito y demuestra [SetUp].
  9. Agrega ProductoService y una prueba async.
  10. Agrega PedidoService y un fake manual.
  11. Agrega SQLite en memoria.
  12. Ejecuta por categorias.
  13. Muestra cobertura con --collect:"XPlat Code Coverage".
  14. Termina con un ejercicio individual.

18. Ejercicios

Ejercicio 1: Calculadora de impuestos

Crea CalculadoraImpuestos con IVA general, reducido y superreducido. Escribe pruebas con [TestCase].

Ejercicio 2: Carrito de compra

Agrega eliminar productos, aplicar descuentos y calcular total. Usa Assert.Multiple.

Ejercicio 3: Servicio asincrono

Crea un servicio que consulte productos de forma asincrona y prueba resultado correcto, error y cancelacion.

Ejercicio 4: Repositorio completo

Amplia ProductoRepository con ObtenerTodosAsync, ActualizarAsync y EliminarAsync.


Resumen

Concepto Sintaxis
Test basico [Test]
Clase de pruebas [TestFixture]
Preparar cada test [SetUp]
Limpiar cada test [TearDown]
Parametrizar [TestCase]
Fuente externa [TestCaseSource]
Asercion moderna Assert.That(valor, Is.EqualTo(...))
Categoria [Category("Unitarias")]
Integracion SQLite en memoria
Ejecutar dotnet test