La lección anterior desacopló Biblioteca de cualquier mecanismo concreto de persistencia,
inyectando IRepositorioBiblioteca en vez de acoplarla a SQLite, JSON o Entity Framework Core. Se
mencionó, de pasada, que esto abriría la puerta a probar Biblioteca sin tocar ningún fichero ni
base de datos real. Esta lección cumple esa promesa: presenta las pruebas unitarias, código
que verifica automáticamente que otro código se comporta como se espera, y las aplica sobre
Prestamo.RegistrarDevolucion() y sobre Biblioteca.PrestarLibroAsync, sustituyendo
IRepositorioBiblioteca por un doble de prueba que no necesita ninguna base de datos real. Con
esto, cualquier cambio futuro en BiblioTech —incluida la refactorización de la última lección de
este módulo— podrá verificarse en segundos, de forma repetible, en vez de comprobarse a mano cada
vez.
Contenido
- Qué es una prueba unitaria y por qué importa
xUnit: instalación y alternativas (MSTest,NUnit)- Estructura Arrange-Act-Assert
[Fact]y[Theory]con[InlineData]- Dobles de prueba: mocks con Moq para
IRepositorioBiblioteca - Cobertura de código: qué mide y sus límites
- Ejemplo: pruebas sobre
Prestamo.RegistrarDevolucion() - Ejemplo: pruebas sobre
Biblioteca.PrestarLibroAsynccon un mock del repositorio
- Qué es una prueba unitaria y por qué importa
Una prueba unitaria es un fragmento de código que ejecuta una unidad pequeña y aislada de tu programa —normalmente un único método— y comprueba automáticamente que su resultado es el esperado, sin intervención manual. La palabra clave es "automáticamente": en vez de ejecutar BiblioTech a mano, hacer un préstamo desde la consola y mirar si el mensaje parece correcto, una prueba unitaria hace exactamente eso mismo en código, y falla de forma ruidosa si el resultado no coincide con lo esperado.
| Sin pruebas unitarias | Con pruebas unitarias |
|---|---|
| Verificar un cambio significa ejecutar la aplicación entera a mano | Verificar un cambio significa ejecutar un conjunto de pruebas en segundos |
| Un error introducido por un cambio se descubre, si acaso, mucho más tarde | Un error se descubre en el momento, antes de llegar a producción |
| Refactorizar (Lección 5) da miedo: ¿seguirá funcionando todo igual? | Refactorizar es seguro: las pruebas existentes confirman que el comportamiento no cambió |
| Repetir la misma comprobación manual una y otra vez es tedioso | Las mismas comprobaciones se ejecutan, iguales, cuantas veces haga falta |
Las pruebas unitarias no sustituyen otras formas de prueba (de integración, manuales, de aceptación con el usuario final), pero son la base más rápida y barata de construir: se ejecutan en milisegundos, sin depender de una base de datos, una red, ni una interfaz gráfica.
xUnit: instalación y alternativas (MSTest, NUnit)
xUnit: instalación y alternativas (MSTest, NUnit)xUnit es uno de los frameworks de pruebas más usados en el ecosistema .NET moderno (es, de
hecho, el que usa el propio equipo de ASP.NET Core para probar su código). Un proyecto de pruebas
xUnit se crea como un proyecto .csproj independiente, separado del proyecto principal:
dotnet new xunit -n BiblioTech.Pruebas
cd BiblioTech.Pruebas
dotnet add reference ../BiblioTech/BiblioTech.csprojdotnet add reference añade una referencia al proyecto principal de BiblioTech, para que el
proyecto de pruebas pueda usar sus clases (Biblioteca, Prestamo, IRepositorioBiblioteca...).
Existen dos alternativas a xUnit, con una filosofía muy similar y solo diferencias de sintaxis:
| Framework | Atributo de prueba | Origen |
|---|---|---|
| xUnit (usado en esta lección) | [Fact] / [Theory] |
El más popular en proyectos .NET modernos y de código abierto |
MSTest |
[TestMethod] |
El framework de pruebas propio de Microsoft, integrado en Visual Studio desde siempre |
NUnit |
[Test] |
Uno de los frameworks de pruebas más antiguos en .NET, inspirado en JUnit de Java |
Esta lección se centra en xUnit por ser la opción más extendida en proyectos nuevos, pero los conceptos (Arrange-Act-Assert, dobles de prueba) se aplican exactamente igual con cualquiera de los tres.
- Estructura Arrange-Act-Assert
Casi cualquier prueba unitaria bien escrita sigue el mismo patrón de tres pasos, conocido como Arrange-Act-Assert (preparar-actuar-comprobar):
[Fact]
public void RegistrarDevolucion_AsignaFechaDevolucion()
{
// Arrange: prepara los datos y objetos necesarios para la prueba
Libro libro = new Libro("Rayuela", "Julio Cortazar", "978-84-376-0495-4");
Socio socio = new Socio(1, "Ana Martinez");
libro.Prestar();
Prestamo prestamo = new Prestamo(libro, socio);
// Act: ejecuta la accion concreta que se quiere probar
prestamo.RegistrarDevolucion();
// Assert: comprueba que el resultado es el esperado
Assert.NotNull(prestamo.FechaDevolucion);
}- Arrange: construye el estado de partida —aquí, un
Libroprestado y unPrestamorecién creado, sin devolver todavía—. - Act: llama al único método que se quiere probar —aquí,
RegistrarDevolucion()—; si esta sección tiene más de una línea, suele ser señal de que la prueba está intentando verificar demasiadas cosas a la vez. - Assert: comprueba, con los métodos de la clase
Assertde xUnit (Assert.Equal,Assert.True,Assert.NotNull,Assert.Throws...), que el resultado obtenido coincide con el esperado.
Esta separación en tres bloques, aunque parezca solo una convención de formato, hace que cualquier prueba se lea de la misma forma en todo el proyecto —el mismo beneficio de consistencia visto en la Lección 1 de este módulo, aplicado ahora a las pruebas.
[Fact] y [Theory] con [InlineData]
[Fact] y [Theory] con [InlineData]xUnit distingue dos tipos de método de prueba:
[Fact]: una prueba con una única entrada, sin parámetros —como la del apartado anterior—.[Theory]: una prueba parametrizada, que se ejecuta varias veces con distintos conjuntos de valores, cada uno declarado con[InlineData(...)].
[Theory]
[InlineData(0, 0)] // sin retraso, sin sancion
[InlineData(5, 0)] // 5 dias de prestamo, dentro del plazo permitido (14 dias), sin sancion
[InlineData(20, 2.00)] // 20 dias, 6 de retraso sobre el plazo de 14: sancion fija de 2.00
public void CalcularSancion_ConSancionFija_DevuelveElValorEsperado(int diasHastaDevolucion, decimal sancionEsperada)
{
// Arrange
Libro libro = new Libro("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1");
Socio socio = new Socio(1, "Ana Martinez");
libro.Prestar();
Prestamo prestamo = new Prestamo(libro, socio);
// Act: se simula el paso del tiempo directamente sobre el calculo (ver Errores Comunes)
prestamo.RegistrarDevolucion();
decimal sancion = prestamo.CalcularSancion(new SancionFija());
// Assert
Assert.True(sancion >= 0); // el ejercicio 1 completa una version mas precisa de esta prueba
}Cada línea [InlineData(...)] genera una ejecución independiente del mismo método de prueba,
con esos valores concretos como argumentos (diasHastaDevolucion, sancionEsperada). En vez de
escribir tres métodos [Fact] casi idénticos —uno por cada combinación de entrada/salida—,
[Theory] los expresa como datos, mucho más compacto y fácil de ampliar con un caso más.
- Dobles de prueba: mocks con Moq para
IRepositorioBiblioteca
IRepositorioBibliotecaUn doble de prueba (test double) es un objeto que sustituye, durante una prueba, a una
dependencia real —una base de datos, un servicio externo, el sistema de ficheros—, para que la
prueba se ejecute rápido, de forma aislada, y sin depender de nada externo. Moq es la librería
más usada en .NET para crear mocks, el tipo de doble de prueba más flexible: un objeto que
implementa una interfaz (como IRepositorioBiblioteca, extraída en la lección anterior) sin
ninguna lógica real, cuyo comportamiento se configura línea a línea en la propia prueba.
using Moq;
Mock<IRepositorioBiblioteca> mockRepositorio = new Mock<IRepositorioBiblioteca>();
// Configura el comportamiento del mock cuando se llame a GuardarCatalogo con cualquier lista
mockRepositorio
.Setup(repo => repo.GuardarCatalogo(It.IsAny<List<MaterialBibliotecario>>()))
.Verifiable(); // marca esta llamada para poder verificar despues que ocurrio
IRepositorioBiblioteca repositorioFalso = mockRepositorio.Object; // el objeto que se inyecta en Biblioteca
// ... usar repositorioFalso como si fuera un IRepositorioBiblioteca real ...
mockRepositorio.Verify(repo => repo.GuardarCatalogo(It.IsAny<List<MaterialBibliotecario>>()), Times.Once);
// Comprueba que GuardarCatalogo se llamo exactamente una vez, sin tocar ningun fichero ni base de datos realMock<IRepositorioBiblioteca> crea un objeto que cumple el contrato de la interfaz sin ninguna
implementación real detrás; .Setup(...) define qué debe pasar cuando se llame a un método
concreto (aquí, simplemente aceptarlo, sin hacer nada más); It.IsAny<...>() acepta cualquier
valor de ese tipo como argumento, sin exigir uno concreto; .Verify(...) comprueba, al final de
la prueba, que una llamada esperada realmente ocurrió. Esta es la razón práctica por la que la
Lección 3 extrajo IRepositorioBiblioteca: sin esa interfaz, no habría nada que sustituir por un
mock, y probar Biblioteca exigiría siempre una base de datos real.
- Cobertura de código: qué mide y sus límites
La cobertura de código (code coverage) mide qué porcentaje de las líneas (o ramas) del
código de producción se ejecutan al correr el conjunto de pruebas. Herramientas como Coverlet
(integrable con dotnet test --collect:"XPlat Code Coverage") generan un informe con ese
porcentaje. Es una métrica útil para detectar zonas del código completamente sin probar, pero
tiene un límite importante que conviene tener claro desde el principio: una cobertura alta no
garantiza pruebas de calidad. Un método puede ejecutarse en una prueba (contar como "cubierto")
sin que esa prueba compruebe realmente nada relevante con Assert. La cobertura es un indicador
de qué falta probar, no un objetivo a maximizar por sí mismo.
- Ejemplo: pruebas sobre
Prestamo.RegistrarDevolucion()
Prestamo.RegistrarDevolucion()Un conjunto más completo de pruebas sobre RegistrarDevolucion(), cubriendo el caso normal y un
caso límite:
public class PrestamoPruebas
{
[Fact]
public void RegistrarDevolucion_AsignaFechaDevolucion()
{
// Arrange
Libro libro = new Libro("Rayuela", "Julio Cortazar", "978-84-376-0495-4");
Socio socio = new Socio(1, "Ana Martinez");
libro.Prestar();
Prestamo prestamo = new Prestamo(libro, socio);
// Act
prestamo.RegistrarDevolucion();
// Assert
Assert.NotNull(prestamo.FechaDevolucion);
}
[Fact]
public void FechaDevolucion_AntesDeDevolver_EsNull()
{
// Arrange
Libro libro = new Libro("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1");
Socio socio = new Socio(1, "Ana Martinez");
libro.Prestar();
Prestamo prestamo = new Prestamo(libro, socio);
// Act: no se llama a RegistrarDevolucion()
// Assert
Assert.Null(prestamo.FechaDevolucion);
}
}La segunda prueba comprueba explícitamente el estado inicial (FechaDevolucion es null
mientras no se ha devuelto), un caso tan importante como el "camino feliz" de la primera prueba:
verifica que Prestamo no asigna una fecha de devolución prematuramente por error.
- Ejemplo: pruebas sobre
Biblioteca.PrestarLibroAsync con un mock del repositorio
Biblioteca.PrestarLibroAsync con un mock del repositorioRetomando Biblioteca de la lección anterior, se amplía PrestarLibroAsync para que, tras
registrar el préstamo, persista el nuevo estado del catálogo a través de
IRepositorioBiblioteca:
class Biblioteca
{
private readonly IRepositorioBiblioteca _repositorio;
public List<MaterialBibliotecario> Catalogo { get; } = new List<MaterialBibliotecario>();
public List<Prestamo> Prestamos { get; } = new List<Prestamo>();
public event Action<Prestamo>? PrestamoRegistrado;
public Biblioteca(IRepositorioBiblioteca repositorio)
{
_repositorio = repositorio;
}
public void RegistrarPrestamo(Prestamo prestamo)
{
Prestamos.Add(prestamo);
PrestamoRegistrado?.Invoke(prestamo);
}
public async Task PrestarLibroAsync(Libro libro, Socio socio)
{
await Task.Delay(1000); // simulacion de una verificacion lenta, Modulo 4
if (!libro.Disponible)
{
throw new InvalidOperationException($"'{libro.Titulo}' no esta disponible para prestamo.");
}
libro.Prestar();
RegistrarPrestamo(new Prestamo(libro, socio));
_repositorio.GuardarCatalogo(Catalogo); // persiste el nuevo estado tras el prestamo
}
}Las pruebas sobre este método, sustituyendo IRepositorioBiblioteca por un mock de Moq:
public class BibliotecaPruebas
{
[Fact]
public async Task PrestarLibroAsync_ConLibroDisponible_GuardaElCatalogo()
{
// Arrange
Mock<IRepositorioBiblioteca> mockRepositorio = new Mock<IRepositorioBiblioteca>();
Biblioteca biblioteca = new Biblioteca(mockRepositorio.Object);
Libro libro = new Libro("Rayuela", "Julio Cortazar", "978-84-376-0495-4");
Socio socio = new Socio(1, "Ana Martinez");
// Act
await biblioteca.PrestarLibroAsync(libro, socio);
// Assert
Assert.False(libro.Disponible);
Assert.Single(biblioteca.Prestamos);
mockRepositorio.Verify(
repo => repo.GuardarCatalogo(It.IsAny<List<MaterialBibliotecario>>()),
Times.Once); // se guardo el catalogo exactamente una vez, sin tocar ninguna base de datos real
}
[Fact]
public async Task PrestarLibroAsync_ConLibroNoDisponible_LanzaExcepcionYNoGuarda()
{
// Arrange
Mock<IRepositorioBiblioteca> mockRepositorio = new Mock<IRepositorioBiblioteca>();
Biblioteca biblioteca = new Biblioteca(mockRepositorio.Object);
Libro libro = new Libro("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1");
libro.Prestar(); // ya prestado de antemano
Socio socio = new Socio(1, "Ana Martinez");
// Act + Assert: Assert.ThrowsAsync ejecuta la accion y comprueba la excepcion esperada
await Assert.ThrowsAsync<InvalidOperationException>(
() => biblioteca.PrestarLibroAsync(libro, socio));
mockRepositorio.Verify(
repo => repo.GuardarCatalogo(It.IsAny<List<MaterialBibliotecario>>()),
Times.Never); // si el prestamo fallo, nunca debe haberse intentado guardar
}
}Ninguna de estas dos pruebas toca un fichero, una base de datos SQLite ni un BibliotecaDbContext
real: mockRepositorio.Object es un IRepositorioBiblioteca de mentira que solo registra qué
llamadas recibió, permitiendo comprobar con Times.Once/Times.Never exactamente cuántas veces
se invocó GuardarCatalogo. La segunda prueba, además, verifica un aspecto tan importante como el
caso de éxito: que un préstamo fallido no provoca un guardado indebido del catálogo.
Errores Comunes y Consejos
- Probar varias cosas distintas en una sola prueba: si un método de prueba tiene varios
bloques
Actseguidos de variosAssertsin relación entre sí, divídelo en pruebas independientes; cuando una prueba falla, su nombre debe bastar para saber exactamente qué se rompió. - Depender de tiempos reales de espera en pruebas (como el
Task.Delay(1000)real dePrestarLibroAsync): en un proyecto real, esa espera se sustituiría por una abstracción del tiempo inyectable (fuera del alcance de esta lección); aquí simplemente asume que las pruebas de este apartado tardan, de hecho, alrededor de un segundo cada una. - Mockear una clase concreta en vez de una interfaz: Moq puede crear mocks de clases con
miembros
virtual, pero es mucho más simple y más habitual mockear interfaces —otra razón práctica para preferir interfaces (IRepositorioBiblioteca) en los puntos de extensión del diseño, como ya viste en la Lección 3. - Perseguir el 100% de cobertura de código como objetivo en sí mismo: es más valioso tener un 80% de cobertura con pruebas que verifiquen comportamientos reales, que un 100% con pruebas vacías que solo ejecutan código sin comprobar nada relevante.
Ejercicios
-
Completa la prueba parametrizada
CalcularSancion_ConSancionFija_DevuelveElValorEsperadodel apartado 4 para que compruebe, conAssert.Equal, el valor exacto esperado en cada caso (necesitarás simular el paso de los días de alguna forma, por ejemplo construyendoPrestamocon una fecha de préstamo ya pasada si tu implementación lo permite, o documentando la limitación si no). -
Escribe una prueba
[Fact]que compruebe queBiblioteca.RegistrarPrestamodispara el eventoPrestamoRegistrado(usa una variable local capturada por la lambda suscrita para comprobar que se invocó).
Soluciones
[Theory]
[InlineData(0, 0)]
[InlineData(2, 4.00)] // 2 dias de retraso sobre 14 permitidos, SancionFija cobra 2.00 fijo
public void CalcularSancion_ConSancionFija_DevuelveElValorEsperado(int diasRetraso, decimal sancionEsperada)
{
// Nota: esta version asume un metodo auxiliar de pruebas que permite fijar FechaPrestamo
// manualmente; documentar esta limitacion es tambien parte de escribir buenas pruebas.
decimal sancionCalculada = new SancionFija().CalcularSancion(diasRetraso);
Assert.Equal(sancionEsperada, sancionCalculada);
}
[Fact]
public void RegistrarPrestamo_DisparaEventoPrestamoRegistrado()
{
// Arrange
Mock<IRepositorioBiblioteca> mockRepositorio = new Mock<IRepositorioBiblioteca>();
Biblioteca biblioteca = new Biblioteca(mockRepositorio.Object);
bool eventoDisparado = false;
biblioteca.PrestamoRegistrado += _ => eventoDisparado = true;
Libro libro = new Libro("Rayuela", "Julio Cortazar", "978-84-376-0495-4");
Socio socio = new Socio(1, "Ana Martinez");
libro.Prestar();
// Act
biblioteca.RegistrarPrestamo(new Prestamo(libro, socio));
// Assert
Assert.True(eventoDisparado);
}
Conclusión
En esta lección has conocido qué es una prueba unitaria y por qué da confianza para cambiar
código sin miedo, has usado xUnit con la estructura Arrange-Act-Assert, [Fact] y [Theory] con
[InlineData], y has sustituido IRepositorioBiblioteca por un mock de Moq para probar
Biblioteca.PrestarLibroAsync de forma rápida y aislada, sin ninguna base de datos real —el
beneficio concreto de haber extraído esa interfaz en la lección anterior. También has visto qué
mide la cobertura de código y por qué no debe perseguirse como fin en sí mismo.
La última lección de este módulo, Revisión y Refactorización de Código, se apoya directamente en
lo aprendido aquí: las pruebas unitarias que acabas de escribir serán la red de seguridad que
permitirá refactorizar con confianza un método largo de Biblioteca, comprobando en cada paso
que su comportamiento no ha cambiado.
Curso de Programación en C#
Módulo 1: Introducción a C#
- Introducción a C#
- Configuración del Entorno de Desarrollo
- Programa Hola Mundo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Arrays y Cadenas de Texto
Módulo 2: Estructuras de Control
Módulo 3: Programación Orientada a Objetos
- Clases y Objetos
- Métodos
- Constructores y Destructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- Structs y Records: Tipos por Valor y por Referencia
Módulo 4: Conceptos Avanzados de C#
- Interfaces
- Delegados y Eventos
- Pattern Matching y Características Modernas de C#
- Genéricos
- Colecciones
- LINQ (Consulta Integrada en el Lenguaje)
- Programación Asíncrona
Módulo 5: Trabajando con Datos
- Entrada/Salida de Archivos
- Serialización
- Conectividad con Bases de Datos
- Entity Framework
- Trabajo con JSON y Consumo de APIs REST
Módulo 6: Temas Avanzados
- Reflexión
- Atributos
- Programación Dinámica
- Gestión de Memoria y Recolección de Basura
- Multihilo y Programación Paralela
Módulo 7: Construcción de Aplicaciones
- Formularios de Windows
- WPF (Windows Presentation Foundation)
- ASP.NET Core
- Blazor
- Xamarin y .NET MAUI
Módulo 8: Mejores Prácticas y Patrones de Diseño
- Estándares de Codificación y Mejores Prácticas
- Patrones de Diseño
- Inyección de Dependencias e Inversión de Control
- Pruebas Unitarias
- Revisión y Refactorización de Código
