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.Corey 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,IntegracionyTestData.
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
- Abre Visual Studio Community.
- Selecciona Crear un proyecto nuevo.
- Busca Blank Solution o Solucion en blanco.
- Nombre:
Tienda.NUnit. - Crea una carpeta
srcy otra carpetatestsdesde el Explorador de soluciones. - En
src, agrega un proyecto Class Library llamadoTienda.Core. - En
tests, agrega un proyecto NUnit Test Project llamadoTienda.Core.Tests. - En
Tienda.Core.Tests, agrega una referencia de proyecto aTienda.Core. - En
Tienda.Core, instala el paquete NuGetMicrosoft.Data.Sqlite. - Comprueba que
Tienda.Core.Testscontiene estos paquetes:Microsoft.NET.Test.SdkNUnitNUnit3TestAdapterNUnit.Analyzerscoverlet.collector
- Crea en el proyecto de pruebas las carpetas
Unitarias,Integracion,TestDatayProperties.
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:
100con10%devuelve90.- Un precio negativo lanza excepcion.
- Un porcentaje menor que
0o mayor que100lanza 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 esTestFixture.
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, novoid. - 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
thispara 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:
- Clic derecho en
Tienda.Core. - Manage NuGet Packages.
- Buscar
Microsoft.Data.Sqlite. - 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
staticmodificables. - 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:
- Abre Test > Test Explorer.
- Agrupa por Traits o filtra por texto.
- Busca
UnitariasoIntegracion. - 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 testfunciona.- 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
0a100, conviene probar0,100y 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
- Abre la solucion original y muestra la estructura.
- Crea una solucion espejo vacia.
- Crea
CalculadoraDescuentos. - Escribe la primera prueba y ejecuta.
- Rompe intencionadamente el codigo para ver una prueba en rojo.
- Corrige el codigo y vuelve a verde.
- Agrega
[TestCase]. - Agrega
Carritoy demuestra[SetUp]. - Agrega
ProductoServicey una pruebaasync. - Agrega
PedidoServicey un fake manual. - Agrega SQLite en memoria.
- Ejecuta por categorias.
- Muestra cobertura con
--collect:"XPlat Code Coverage". - 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 |