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

  1. Qué es una prueba unitaria y por qué importa
  2. xUnit: instalación y alternativas (MSTest, NUnit)
  3. Estructura Arrange-Act-Assert
  4. [Fact] y [Theory] con [InlineData]
  5. Dobles de prueba: mocks con Moq para IRepositorioBiblioteca
  6. Cobertura de código: qué mide y sus límites
  7. Ejemplo: pruebas sobre Prestamo.RegistrarDevolucion()
  8. Ejemplo: pruebas sobre Biblioteca.PrestarLibroAsync con un mock del repositorio

  1. 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.

  1. 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.csproj

dotnet 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.

  1. 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 Libro prestado y un Prestamo recié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 Assert de 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.

  1. [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.

  1. Dobles de prueba: mocks con Moq para IRepositorioBiblioteca

Un 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.

dotnet add package Moq
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 real

Mock<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.

  1. 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.

  1. Ejemplo: pruebas sobre 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.

  1. Ejemplo: pruebas sobre Biblioteca.PrestarLibroAsync con un mock del repositorio

Retomando 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 Act seguidos de varios Assert sin 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 de PrestarLibroAsync): 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

  1. Completa la prueba parametrizada CalcularSancion_ConSancionFija_DevuelveElValorEsperado del apartado 4 para que compruebe, con Assert.Equal, el valor exacto esperado en cada caso (necesitarás simular el paso de los días de alguna forma, por ejemplo construyendo Prestamo con una fecha de préstamo ya pasada si tu implementación lo permite, o documentando la limitación si no).

  2. Escribe una prueba [Fact] que compruebe que Biblioteca.RegistrarPrestamo dispara el evento PrestamoRegistrado (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#

Módulo 2: Estructuras de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Conceptos Avanzados de C#

Módulo 5: Trabajando con Datos

Módulo 6: Temas Avanzados

Módulo 7: Construcción de Aplicaciones

Módulo 8: Mejores Prácticas y Patrones de Diseño

Módulo 9: Proyecto Final

© Copyright 2026. Todos los derechos reservados