Pruebas Unitarias en .Net Core con xUnit – 5 – Controladores API, Entity Framework y InMemory Database

Pruebas Unitarias en .Net Core con xUnit – 5 – Controladores API, Entity Framework y InMemory Database

La intención del Post de esta semana era aprovechar una guía como probar un Controlador de API MVC con dependencia de Entity Framework (EF), y aprovechar el post para empezar a mostrar el uso de la librería Moq. Este es un caso que hice en varios proyectos cuando .Net Core era nuevo.

El problema es, que el proceso de configurar Moq para simular el DbContext y sus DbSet requiere demasiada ceremonia y trabajo de configuración, y como primer acercamiento con Moq puede ser muy confuso. Adicionalmente creo que verdadero valor es saber probar los Controladores de API que dependen del Entity Framework, y la manera más fácil y rápida de hacer esto es usando el proveedor InMemory. Adicionalmente, según el equipo de Entity Framework, en vez de hacer Mocks para simular el contexto, lo correcto es usar esta librería. InMemory simula todo el funcionamiento de Entity Framework, pero sin la conexión a la base de datos, simulándola con objetos en memoria. Esta librería fue creada específicamente para facilitar las pruebas unitarias.

Para este post es necesario que tengas muy claro la inyección de dependencia. Si no lo tienes te recomiendo leer primero este post:

El Problema

La idea es muy sencilla. Tienes un API, que depende de Entity Framework directamente para trabajar con la Base de Datos. ¿Como agregas pruebas unitarias en este caso? EF no es una clase sencilla que puedas simular con simplemente extraer la interfaz, como vimos en los Posts anteriores. Aunque lo correcto sería que el controlador dependiera otro tipo de clase, respetando el patrón de Repositorio o el patrón de Servicios, muchas veces preferimos utilizar el Entity Framework directo porque es más rápido y podemos aprovechar la generación automática de Visual Studio. De todos modos, no olvides considerar la migración a Repositorios o Servicios para el futuro.

En general la solución de ejemplo es muy sencilla. Es una solución genérica de Web API en .Net Core creada por Visual Studio. Removimos WeatherForecastControler y WeatherForecast porque no los vamos a usar, y agregamos las referencias a Microsoft.EntityFrameworkCore y Microsoft.EntityFrameworkCore.Tools utilizando NuGet. Creamos las carpetas Data y Model, agregamos la clase ApplicationDbContext y sus configuraciones en Startup.cs, incluyendo el ConnectionString en appsettings.json. Esta es la configuración básica de una solución que va a usar EF. No voy a detallar el proceso completo ya que tiene más que ver con EF que con pruebas unitarias, pero si te interesa que lo cubra en un Post futuro deja tu comentario para ponerlo en el Backlog. De todos modos, voy a empezar a dejar ligas a mis repositorios en GitHub para que puedas descargar la solución completa.

Una vez que tenemos el EF configurado, creamos nuestra primer clase Cliente en la carpeta Model, esta es la clase que vamos a estar guardando a Base de Datos:

 
//Cliente.cs
public class Cliente
{
    [Key]
    public Guid IdCliente { get; set; }

    [Required, MinLength(1), MaxLength(100)]
    public string Nombre { get; set; }

    [MaxLength(100)]
    public string Apellido { get; set; }

    [Required, MinLength(1), MaxLength(320)]
    public string EMail { get; set; }

    [Required]
    public bool EnviarCorreos { get; set; }
}

Posteriormente DBSet para Cliente en ApplicationDbContext:

//ApplicationDbcontext.cs
public class ApplicationDbContext : DbContext
{
    public ApplicationDbContext(DbContextOptions options) : base(options) { }

    public DbSet<Cliente> Clientes { get; set; }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);
    }
}

public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<ApplicationDbContext>
{
    public ApplicationDbContext CreateDbContext(string[] args)
    {
        IConfigurationRoot configuration = new ConfigurationBuilder().SetBasePath(Directory.GetCurrentDirectory()).AddJsonFile(@Directory.GetCurrentDirectory() + "/appsettings.json").Build();
        var builder = new DbContextOptionsBuilder<ApplicationDbContext>();
        var connectionString = configuration.GetConnectionString("DefaultConnection");
        builder.UseSqlServer(connectionString);
        return new ApplicationDbContext(builder.Options);
    }
}

Y creamos la migración (Add-Migration), esto nos crea el directorio Migrations con la migración para crear la tabla y el snapshot. Posteriormente creamos y agregamos la tabla a la Base de Datos (Update-Database).

Y finalmente agregamos el controlador. Este lo agregamos de manera automática utilizando la generación de Visual Studio (scaffolding), sin ningún cambio. Para hacerlo, damos clic derecho en la carpeta Controllers -> Add -> Controller.

En la pantalla que nos aparece seleccionamos Common -> API – > API Controller with actions, using Entity Framework, y damos clic en Add.

En la siguiente pantalla seleccionamos en Model Class en nuestra clase Cliente, y finalizamos dando clic en Add.

Y listo, esto nos crea automáticamente el controlador que vamos a usar. Recuerda que vamos a utilizar este controlador tal cual, sin ninguna modificación.

 
//ClientesController.cs
[Route("api/[controller]")]
[ApiController]
public class ClientesController : ControllerBase
{
    private readonly ApplicationDbContext _context;

    public ClientesController(ApplicationDbContext context)
    {
        _context = context;
    }

    // GET: api/Clientes
    [HttpGet]
    public async Task<ActionResult<IEnumerable<Cliente>>> GetClientes()
    {
        return await _context.Clientes.ToListAsync();
    }

    // GET: api/Clientes/5
    [HttpGet("{id}")]
    public async Task<ActionResult<Cliente>> GetCliente(Guid id)
    {
        var cliente = await _context.Clientes.FindAsync(id);

        if (cliente == null)
        {
            return NotFound();
        }

        return cliente;
    }

    // PUT: api/Clientes/5
    // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
    [HttpPut("{id}")]
    public async Task<IActionResult> PutCliente(Guid id, Cliente cliente)
    {
        if (id != cliente.IdCliente)
        {
            return BadRequest();
        }

        _context.Entry(cliente).State = EntityState.Modified;

        try
        {
            await _context.SaveChangesAsync();
        }
        catch (DbUpdateConcurrencyException)
        {
            if (!ClienteExists(id))
            {
                return NotFound();
            }
            else
            {
                throw;
            }
        }

        return NoContent();
    }

    // POST: api/Clientes
    // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
    [HttpPost]
    public async Task<ActionResult<Cliente>> PostCliente(Cliente cliente)
    {
        _context.Clientes.Add(cliente);
        await _context.SaveChangesAsync();

        return CreatedAtAction("GetCliente", new { id = cliente.IdCliente }, cliente);
    }

    // DELETE: api/Clientes/5
    [HttpDelete("{id}")]
    public async Task<IActionResult> DeleteCliente(Guid id)
    {
        var cliente = await _context.Clientes.FindAsync(id);
        if (cliente == null)
        {
            return NotFound();
        }

        _context.Clientes.Remove(cliente);
        await _context.SaveChangesAsync();

        return NoContent();
    }

    private bool ClienteExists(Guid id)
    {
        return _context.Clientes.Any(e => e.IdCliente == id);
    }
}

Identificar Dependencias y su Funcionamiento

El siguiente paso es identificar todas las dependencias que tiene el controlador. Usualmente analizaríamos el constructor y todos los métodos de la clase para identificar que funciones y dependencias hay, pero en este caso la única dependencia es EF, y ya que el proveedor InMemory Database implementa todos los métodos necesarios, no hace falta analizar toda la clase. Lo importante es entender bien el Constructor.

  • Constructor – El constructor recibe un objeto ApplicationDbContext que utiliza para inicializar la variable global _context. Regresando a los posts anteriores, ve como este controlador implementa en automático la inyección de dependencia de esta clase, lo cual nos permitirá agregar las pruebas unitarias.

Creamos el Proyecto de Pruebas y las Pruebas Unitarias

Y muy bien, ya que la dependencia de ApplicationDbContext será completada utilizando el proveedor InMemory Database, el siguiente paso es empezar a hacer las pruebas unitarias. Para esto agregamos un segundo proyecto para las pruebas, y en este proyecto usamos NuGet para agregar referencias a Microsoft.EntityFrameworkCore y Microsoft.EntityFrameworkCore.InMemory. Lo siguiente es agregar la clase para realizar las pruebas. En este caso únicamente agregamos una prueba por método. Nuestra clase termina así:

 
//ClientesController_Should.cs
public class ClientesController_Should
{
    readonly ApplicationDbContext _context;

    public ClientesController_Should()
    {
        var options = new DbContextOptionsBuilder<ApplicationDbContext>()
            .UseInMemoryDatabase(databaseName: "TestDB")
            .Options;

        _context = new ApplicationDbContext(options);
        _context.Clientes.Add(
            new Cliente()
            {
                Nombre = "Pedro",
                Apellido = "Paramo",
                EMail = "pedro.paramo@juanrulfo.com",
                EnviarCorreos = true,
            });
        _context.Clientes.Add(
            new Cliente()
            {
                Nombre = "Juan",
                Apellido = "Preciado",
                EMail = "juan.preciado@juanrulfo.com",
                EnviarCorreos = true,
            });
        _context.SaveChangesAsync();
    }

    [Fact]
    public async void GetClientes_Test()
    {
        //Arrange
        var sut = new ClientesController(_context);

        //Act
        var resp = await sut.GetClientes();

        //Assert
        Assert.NotEmpty(resp.Value);
    }

    [Fact]
    public async void PutCliente_Test()
    {
        //Arrange
        var sut = new ClientesController(_context);
        var clienteACambiar = _context.Clientes.FirstOrDefault();
        clienteACambiar.Nombre = "Nuevo Nombre";
        clienteACambiar.Apellido = "Nuevo Apellido";
        clienteACambiar.EMail = "nuevo@corre.com";
        clienteACambiar.EnviarCorreos = true;

        //Act
        var resp = await sut.PutCliente(clienteACambiar.IdCliente, clienteACambiar);
        var respAsResult = (NoContentResult)resp;

        //Assert
        var clienteCambiado = _context.Clientes.First(m => m.IdCliente == clienteACambiar.IdCliente);
        Assert.Equal(204, respAsResult.StatusCode);
        Assert.Equal("Nuevo Nombre", clienteCambiado.Nombre);
        Assert.Equal("Nuevo Apellido", clienteCambiado.Apellido);
        Assert.Equal("nuevo@corre.com", clienteCambiado.EMail);
        Assert.True(clienteACambiar.EnviarCorreos);
    }

    [Fact]
    public async void PostCliente_Test()
    {
        //Arrange
        var newCliente = new Cliente()
        {
            Nombre = "Juan",
            Apellido = "Rulfo",
            EMail = "juan.rulfo@juanrulfo.com",
            EnviarCorreos = true,
        };
        var sut = new ClientesController(_context);

        //Act
        var resp = await sut.PostCliente(newCliente);
        var respValue = (CreatedAtActionResult)resp.Result;
        var respCln = (Cliente)respValue.Value;

        //Assert
        Assert.Equal(201, respValue.StatusCode);
        Assert.NotEqual(Guid.Empty, respCln.IdCliente); //Aqui estamos asumiendo funcionamiento que no es del controlador.
        Assert.Equal(newCliente.Nombre, respCln.Nombre);
        Assert.Equal(newCliente.Apellido, respCln.Apellido);
        Assert.Equal(newCliente.EMail, respCln.EMail);
        Assert.Equal(newCliente.EnviarCorreos, respCln.EnviarCorreos);
    }

    [Fact]
    public async void DeleteCliente_Test()
    {
        //Arrange
        var sut = new ClientesController(_context);
        var clienteABorrar = _context.Clientes.FirstOrDefault();

        //Act
        var resp = await sut.DeleteCliente(clienteABorrar.IdCliente);
        var respAsResult = (NoContentResult)resp;

        //Assert
        Assert.Equal(204, respAsResult.StatusCode);
        var clienteBorrado = _context.Clientes.FirstOrDefault(m => m.IdCliente == clienteABorrar.IdCliente);
        Assert.Null(clienteBorrado);
    }
}

Y ahora para entender lo que pasa en toda la prueba. Agregamos las funciones de pruebas en el mismo orden que aparece en el controlador.

Configuración (Setup)

Antes de la primera prueba, vamos a hacer un Setup general para todas las pruebas. Esto lo podríamos hacer en el Arrange de cada prueba, pero lo ponemos aquí con la finalidad de bajar reducir el código duplicado.

  1. Primero agregamos una variable global (_context) donde vamos a almacenar el ApplicationDbContext, este lo inyectaremos al controller en cada prueba.
  2. Lo siguiente es el constructor de la clase, que utilizamos para hacer el Setup (configuración) de la prueba. En este primero creamos un objeto DbContextOptionsBuilder llamado options, el cual utilizaremos para decirle a EF que utilizaremos una Base de Datos en memoria. El nombre de la base de datos no importa en el contexto de las pruebas, podemos poner el que sea.
  3. Inicializamos la variable _context, inyectando el objeto options. En este momento podemos empezar a utilizar el contexto, y EF guardará la información en la Base de Datos en memoria.
  4. Ahora agregamos 2 clientes a nuestra tabla de Clientes. Estos los usaremos para las pruebas.
  5. Ejecutamos SaveChangesAsync para guardar los clientes en la tabla.

RECUERDA: Recuerda estos 3 puntos cuando estés diseñando tus pruebas:

  • xUnit instancia la clase de pruebas por cada prueba que encuentra. En este caso hay 4 pruebas, por lo que se instancia 4 veces.
  • El orden en el que xUnit ejecuta las pruebas es aleatorio, no está garantizado.
  • InMemory DB se crea solamente 1 vez por ejecución de la aplicación, así que la información que ponemos en esta sobrevive las ejecuciones, no importando si utilizas Dispose.

Recuerda todo esto porque, si quisieras validar el número total de Clientes que hay en el contexto, tal vez después de hacer el Put o el Delete, el numero puede cambiar entre ejecuciones dependiendo del orden en el que se ejecutó la prueba.

GetClientes_Test

La función para traer el listado completo de los Clientes.

  1. Arrange
    1. Inicializamos el controlador con el contexto creado.
  2. Act
    1. Ejecutamos la llamada función GetClientes del controlador.
  3. Assert
    1. Verificamos que el listado regresado no esté vacío.

PutCliente_Test

La función para actualizar la información de un cliente.

  1. Arrange
    1. Inicializamos el controlador.
    2. Extraemos el primer cliente de la lista. Puedes sacar uno en específico, pero como no conocemos los Id que se les asignaron, simplemente sacamos el primero.
    3. Cambiamos los datos del cliente.
  2. Act
    1. Ejecutamos la función PutCliente para cambiar la información del cliente.
    2. Ya que el resultado es un IActionResult, pero sabemos que esperamos un NoContentResult, hacemos el casting a CreatedAtActionResult.
  3. Assert
    1. Verificamos que la respuesta sea un 204 (No Content).
    2. Aquí tenemos que validar que el objeto que enviamos realmente cambio en la Base de Datos, por lo que extraemos el objeto del contexto, utilizando el ID del cliente original.
    3. Nos aseguramos de que el objeto que sacamos de la base tenga los mismos datos que insertamos.

En esta prueba la respuesta es simplemente IActionResult que es una interfaz, si quieres saber el tipo específico del objeto, puedes ejecutar la prueba en modo debug, y ver el tipo de objeto con simplemente poner el mouse sobre él.

PostCliente_Test

La función para agregar un cliente nuevo.

  1. Arrange
    1. Creamos el objeto de cliente que vamos a agregar.
    2. Inicializamos el controlador.
  2. Act
    1. Ejecutamos la función PostCliente, enviando el cliente.
    2. Como la respuesta es un IActionResult, hacemos el casting a un CreatedAtActionResult.
    3. Posteriormente, como el Value de CreatedAtActionResult es tipo object, hacemos un casting a la clase Cliente.
  3. Assert
    1. Verificamos que el StatusCode de la respuesta sea 201 (Created).
    2. Verificamos que se haya creado un Id correcto al nuevo cliente.
    3. Verificamos que tenga todos los datos asignados.

DeleteCliente_Test

La función para borrar un cliente.

  1. Arrange
    1. Inicializamos el controlador.
    2. Extraemos el cliente que vamos a borrar. Usamos FirstOrDefault porque no tenemos Ids específicos. Pero cualquier cliente servirá para la prueba.
  2. Act
    1. Ejecutamos la función DeleteCliente, enviando el Id del cliente que queremos borrar.
    2. Como la respuesta es un IActionResult, hacemos el casting a un NoContentResult.
  3. Assert
    1. Verificamos que la respuesta sea un 204 (No Content).
    2. Tratamos de extraer el cliente borrado de la base de datos.
    3. Verificamos que el cliente sea NULL, lo que significa que fue borrado de la base de datos.

Y bien, con eso tenemos las pruebas unitarias para poder probar nuestro controlador. No son pruebas exhaustivas, pero es un buen inicio. Recuerda agregar casos adicionales, en especial si has agregado lógica de negocio al API. Validar cada uno de los datos en el objeto puede ser excesivo, pero si hay alguna lógica de negocio que cambia la información, o agrega información nueva, recuerda probarlo.

Esta misma técnica la puedes utilizar en cualquier proyecto que utilice Entity Framework, por ejemplo, si estas usando un patrón de Repositorio. Lo único que necesitas es que el repositorio reciba el contexto por medio de Inyección de Dependencia.

Resumen

Cuando estamos haciendo una API web, con Entity Framework, y aprovechando la generación automática de Visual Studio, podemos agregar pruebas unitarias a nuestros controladores utilizando el proveedor InMemory. Este proveedor simula la interacción de EF con una Base de Datos real, pero usando objetos en memoria. Con esto podemos sustituir el generador de la clase Contexto y realizar nuestras pruebas sin necesidad de una conexión real.

Para implementarlo recuerda:

  • En tu proyecto de pruebas, agrega referencias a Microsoft.EntityFrameworkCore y Microsoft.EntityFrameworkCore.InMemory.
  • En tu prueba unitaria, crea el objeto DbContextOptionsBuilder con la opción UseInMemoryDatabase().
  • Inicializa tu contexto inyectando el objeto DbContextOptionsBuilder que creaste.
  • Este nuevo contexto lo puede usar para simular todas las llamadas a EF, pero sin conexión a una base de datos real.
  • Puedes utilizar esta técnica para probar cualquier clase que depende de EF.

Repositorio del proyecto:
https://github.com/dansuarmar/unitTestXunitBasics/tree/main/P5%20-%20API%20Controller%20y%20EF/unitTestXunitBasics

Deja un comentario

Close Menu