Pruebas unitarias en Entity Framework Core - SqLite in-memory

Sin duda, uno de los puntos más críticos y susceptibles a errores en el desarrollo de aplicaciones web es el acceso a datos. Es por esto que realizar unas pruebas unitarias sólidas y robustas que aseguren una correcta interacción con la base de datos, es una muy buena práctica.

Antes de la salida de la versión Core, Entity Framework nos permitía utilizar el contexto de datos para simular de una manera bastante simplista una base de datos sobre la que realizar las pruebas unitarias de nuestros servicios de acceso a datos. Afortunadamente nuevo el núcleo de Entity Framework Core, nos proporciona una forma de realizar pruebas unitarias sobre una base de datos en memoria, simulando así un escenario lo más parecido posible al que nos proporcionaría una base de datos real.

En este Post veremos cómo crear un contexto de datos (DbContext) enlazado a una base de datos SqLite in-memory, que nos permitirá realizar las pruebas unitarias oportunas sobre un servicio estándar de acceso a datos (CRUD).

El escenario de pruebas

En primer lugar crearemos la clase de servicio sobre la que posteriormente realizaremos las pruebas unitarias PaisesDataService.cs. Esta clase se encargará de realizar las operaciones básicas de acceso a datos (CRUD) sobre una entidad de modelo de ejemplo Pais.cs.

    public class PaisesDataService
    {
        private readonly ApplicationDbContext context;

        public PaisesDataService(ApplicationDbContext context)
        {
            this.context = context;
        }

        public async Task<IEnumerable<Pais>> ReadPaises()
        {
            return await this.context.Paises.ToListAsync();
        }

        public async Task<Pais> ReadPais(int id)
        {
            return await this.context.Paises.SingleOrDefaultAsync(m => m.Id == id);
        }

        // RETORNA 0 SI NO SE HA EJECUTADO LA ACCIÓN O SI HA HABIDO UN ERROR
        public async Task<int> UpdatePais(Pais pais)
        {
            var _result = 0;
            this.context.Entry(pais).State = EntityState.Modified;
            try
            {
                _result = await this.context.SaveChangesAsync();
            }
            catch (DbUpdateConcurrencyException ex)
            {
                _result = 0;
            }
            catch (DbUpdateException ex)
            {
                _result = 0;
            }

            if (!await PaisExists(pais.Id))
            {
                _result = 0;
            }

            return _result;
        }

        // RETORNA 0 SI NO SE HA EJECUTADO LA ACCIÓN O SI HA HABIDO UN ERROR
        public async Task<int> CreatePais(Pais pais)
        {
            var _result = 0;
            this.context.Paises.Add(pais);
            try
            {
                _result = await this.context.SaveChangesAsync();
            }
            catch (DbUpdateConcurrencyException)
            {
                _result = 0;
            }
            catch (DbUpdateException)
            {
                _result = 0;
            }

            return _result;
        }

        // RETORNA 0 SI NO SE HA EJECUTADO LA ACCIÓN O SI HA HABIDO UN ERROR
        public async Task<int> DeletePais(int id)
        {
            var _result = 0;
            var pais = await this.context.Paises.SingleOrDefaultAsync(m => m.Id == id);

            if (pais == null)
            {
                _result = 0;
            }
            else
            {                
                this.context.Paises.Remove(pais);                
                try
                {
                    _result = await this.context.SaveChangesAsync();
                }
                catch (DbUpdateConcurrencyException)
                {
                    _result = 0;
                }
                catch (DbUpdateException)
                {
                    _result = 0;
                }
            }
            return _result;
        }

        private async Task<bool> PaisExists(int id)
        {
            return await this.context.Paises.AnyAsync(e => e.Id == id);
        }
    }

    
    public class Pais
    {
        [Key]
        public int Id { get; set; }
        [Required]        
        [StringLength(50, MinimumLength = 3)]
        public string Nombre { get; set; }
        public int Habitantes { get; set; }
    }

Como podemos observar, la clase de servicio PaisesDataService recibe como parámetro a través del constructor, una instancia del contexto de datos de la aplicación ApplicationDbContext. Este contexto de datos es el que utilizara nuestro servicio para "enlazar" con la base de datos real de la aplicación.

    public class ApplicationDbContext: DbContext
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) 
        : base (options)
        {                
        }
        public DbSet<Pais> Paises { get; set; }
    }

Como vemos, el DbContext recibe a través del constructor las opciones de configuración (tipo de base de datos, ConnectionString, etc.) mediante inyección de dependencias, al estilo de las aplicaciones .NET Core (para más información recomiendo lean el siguiente Post Entity Framework Core Database First en aplicaciones .NET MVC).

Las pruebas unitarias

Una vez construido el escenario de pruebas de ejemplo, pasaremos a crear un nuevo proyecto de pruebas unitarias del tipo Proyecto de prueba de MSTest(.NET Core). Para este ejemplo se ha utilizado Visual Studio Community 2017 (con todas las actualizaciones al día).

Por supuesto, es necesario añadir una referencia (dependencia) al proyecto sobre el que vamos a realizar las pruebas (el escenario de pruebas anteriormente creado).

El contexto de datos para las pruebas (Fake DbContext)

En este punto, ya disponemos de toda la infraestructura necesaria para comenzar a programar nuestras pruebas unitarias. 

Comenzaremos creando una clase (SqLiteDbFake.cs) que nos devuelva una instancia de un contexto de datos "Fake" o falso, que actuará sobre una base de datos SqLite en memoria. Este contexto, será un implementación del DbContext original de la aplicación (ApplicationDbContext).

    public class SqLiteDbFake
    {
        private DbContextOptions<ApplicationDbContext> options;

        public SqLiteDbFake()
        {
            options = GetDbContextOptions;
        }

        public ApplicationDbContext GetDbContext()
        {
            var context = new ApplicationDbContext(options);
            // Crea y abre el 'schema' en la base de datos
            context.Database.EnsureCreated();
            return context;
        }

        private DbContextOptions<ApplicationDbContext> GetDbContextOptions
        {
            get
            { 
                // La BD in-memory solo existe cuando la conexión está abierta
                var connection = new SqliteConnection("DataSource=:memory:");
                connection.Open();

                var options = new DbContextOptionsBuilder<ApplicationDbContext>()
                        .UseSqlite(connection)
                        .Options;

                return options;
            }
        }
    }

Podemos ver, que básicamente lo que hace esta clase, es sustituir las opciones de construcción del DbContext original (ApplicationDbContext), por otras donde la base de datos será un SqLite in-memory.

El método GetDbContext(), nos devolverá siempre un contexto de datos "limpio" sobre una base de datos SqLite en memoria ya existente.

La clase de pruebas unitarias (TestClass)

Por último, crearemos las clase de pruebas unitarias (PaisesDataServiceTests.cs) para el servicio de acceso a datos PaisesDataService.cs.

    [TestClass]
    public class PaisesDataServiceTests
    {
        // Clase Base de Datos Fake
        private SqLiteDbFake sqLiteDbFake;

        [TestInitialize]
        public void Init()
        {
            sqLiteDbFake = new SqLiteDbFake();
        }
        ...
        ...
        // Aquí todas las pruebas unitarias...    
        ...
        ...
     }

Como vemos en el código, en el método "inicializador" de la clase, creamos una instancia de la clase sqLiteDbFake para posteriormente poder crear contextos de datos independientes que serán utilizados en las pruebas unitarias (TestMethod).

Programando las pruebas unitarias (TestMethod)

Para obtener una mejor legibilidad del código y una estructura fácilmente mantenible, organizaremos las pruebas en función de su cometido. En nuestro caso (un CRUD) las dividiremos en Create, Read, Update y Delete.

Debemos tener también en cuenta que los nombres de las pruebas deben de ser lo suficientemente descriptivos como para saber a primera vista cual es su función.

Pruebas 'Create'

        [TestMethod]
        public void CreatePais_Crear_objeto_Pais_valido_en_BD_devuelve_num_registros_insertados_Async()
        {
            // ARRANGE
            // Creamos un Objeto Pais Válido
            var paisTest = new Pais() { Nombre = "DataTest", Habitantes = 1000 };

            // Creamos un DbContext limpio para hacer las pruebas
            using (var context = sqLiteDbFake.GetDbContext())
            {
                // ACT
                var paisesDataService = new PaisesDataService(context);
                // Invocamos el Método que estamos probando 'CreatePais(Pais pais)'
                var result = paisesDataService.CreatePais(paisTest);

                // ASSERT
                // Aseguramos que el resultado del método 'CreatePais(pais)' es distinto de 0.
                Assert.AreNotEqual(0, result.Result);                
            }

            // Creamos un DbContext limpio para hacer las pruebas
            using (var context = sqLiteDbFake.GetDbContext())
            {
                // ASSERT
                // Asegurammos que solo hay un registro en la BD.
                Assert.AreEqual(1, context.Paises.Count());
                // Aseguramos que el registro creado es el que añadimos anteriormente.
                Assert.AreEqual("DataTest", context.Paises.Single().Nombre);
            }        
        }

        [TestMethod]
        public void CreatePais_Crear_objeto_Pais_invalido_en_BD_devuelve_cero_Async()
        {
            // ARRANGE
            // Creamos un Objeto Pais Inválido (el Nombre es requerido). 
            var paisTest = new Pais() { Nombre = null, Habitantes = 1000 };

            // Creamos un DbContext limpio para hacer las pruebas
            using (var context = sqLiteDbFake.GetDbContext())
            {
                // ACT
                var paisesDataService = new PaisesDataService(context);
                // Invocamos el Método que estamos probando 'CreatePais(Pais pais)'
                var result = paisesDataService.CreatePais(paisTest);

                // ASSERT     
                // Aseguramos que el resultado del método 'CreatePais(pais)' es 0.
                Assert.AreEqual(0, result.Result);                
            }

            // Creamos un DbContext limpio para hacer las pruebas
            using (var context = sqLiteDbFake.GetDbContext())
            {
                // ASSERT     
                // Asegurammos que No hay un registros en la BD.
                Assert.AreEqual(0, context.Paises.Count());
            }
        }

Como podemos observar en las pruebas, cada vez que lo necesitamos, creamos un nuevo contexto de datos (var context = sqLiteDbFake.GetDbContext()) que posteriormente "inyectaremos" a través del constructor al servicio de acceso a datos (var paisesDataService = new PaisesDataService(context)). 

Para el resto de las pruebas unitarias, el procedimiento será básicamente el mismo.

Pruebas 'Read'

        [TestMethod]
        public void ReadPaises_Retornar_todos_los_Paises_de_la_BD_Async()
        {
            using (var context = sqLiteDbFake.GetDbContext())
            {
                // ARRANGE
                context.Paises.Add(new Pais() { Nombre = "DataTest1", Habitantes = 1000 });
                context.Paises.Add(new Pais() { Nombre = "DataTest2", Habitantes = 1000 });
                context.Paises.Add(new Pais() { Nombre = "DataTest3", Habitantes = 1000 });
                context.SaveChanges();
            }

            using (var context = sqLiteDbFake.GetDbContext())
            {
                // ACT
                var paisesDataService = new PaisesDataService(context);
                var result = paisesDataService.ReadPaises();

                // ASSERT
                Assert.AreEqual(3, result.Result.Count());
            }
        }

        [TestMethod]
        public void ReadPais_Retornar_un_Pais_que_existe_en_BD_Async()
        {            
            using (var context = sqLiteDbFake.GetDbContext())
            {
                // ARRANGE
                var paisTest = new Pais() { Nombre = "DataTest", Habitantes = 1000 };
                context.Paises.Add(paisTest);
                context.SaveChanges();
            }

            using (var context = sqLiteDbFake.GetDbContext())
            {
                // ACT
                var paisesDataService = new PaisesDataService(context);
                var pais = context.Paises.Single();
                var result = paisesDataService.ReadPais(pais.Id);

                // ASSERT
                Assert.IsNotNull(result.Result);
                Assert.AreEqual(pais.Nombre, result.Result.Nombre);
            }
        }

        [TestMethod]
        public void ReadPais_Retornar_NULL_si_Pais_No_existe_en_BD_Async()
        {
            // Creamos un DbContext limpio para hacer las pruebas
            using (var context = sqLiteDbFake.GetDbContext())
            {                
                // ACT
                var paisesDataService = new PaisesDataService(context);
                var result = paisesDataService.ReadPais(1);

                // ASSERT
                Assert.IsNull(result.Result);
            }
        }

Pruebas 'Update'

        [TestMethod]
        public void UpdatePais_Modificar_Pais_existente_en_BD_devuelve_num_registros_modificados_Async()
        {
            using (var context = sqLiteDbFake.GetDbContext())
            {
                // ARRANGE
                var paisTest = new Pais() { Nombre = "DataTest", Habitantes = 1000 };
                context.Paises.Add(paisTest);
                context.SaveChanges();
            }

            // Creamos un DbContext limpio para hacer las pruebas
            using (var context = sqLiteDbFake.GetDbContext())
            {
                // ACT
                var pais = context.Paises.Single();
                // Desconectamos el Objeto 'pais' del Contexto para poder modificarlo posteriormente.
                context.Entry(pais).State = EntityState.Detached;
                var paisModificado = new Pais() { Id = pais.Id, Nombre = "DataTestModificado", Habitantes = 1000 };
                var paisesDataService = new PaisesDataService(context);
                var result = paisesDataService.UpdatePais(paisModificado);

                // ASSERT
                Assert.AreNotEqual(0, result.Result);
                Assert.AreEqual(1, result.Result);
                Assert.AreEqual("DataTestModificado", context.Paises.Single().Nombre);
            }
        }

        [TestMethod]
        public void UpdatePais_Modificar_Pais_inexistente_en_BD_devuelve_cero_Async()
        {
            // Creamos un DbContext limpio para hacer las pruebas
            using (var context = sqLiteDbFake.GetDbContext())
            {
                // ACT
                var paisModificado = new Pais() { Id = 1, Nombre = "DataTestModificado", Habitantes = 1000 };
                var paisesDataService = new PaisesDataService(context);
                var result = paisesDataService.UpdatePais(paisModificado);
                // ASSERT
                Assert.AreEqual(0, result.Result);
            }
        }

Pruebas 'Delete'

        [TestMethod]
        public void DeletePais_Eliminar_objeto_Pais_existente_en_BD_devuelve_num_registros_eliminados_Async()
        {
            //Pais paisTest;
            using (var context = sqLiteDbFake.GetDbContext())
            {
                // ARRANGE
                var paisTest = new Pais() { Nombre = "DataTest", Habitantes = 1000 };
                context.Paises.Add(paisTest);
                context.SaveChanges();
            }

            using (var context = sqLiteDbFake.GetDbContext())
            {
                // ACT
                var paisesDataService = new PaisesDataService(context);
                var result = paisesDataService.DeletePais(context.Paises.Single().Id);

                // ASSERT
                Assert.AreEqual(1, result.Result);
                // Asegurammos que no hay registros en la BD.
                Assert.AreEqual(0, context.Paises.Count());
            }
        }

        [TestMethod]
        public void DeletePais_Eliminar_objeto_Pais_inexistente_en_BD_devuelve_cero_Async()
        {            
            using (var context = sqLiteDbFake.GetDbContext())
            {
                // ACT
                var paisesDataService = new PaisesDataService(context);
                var result = paisesDataService.DeletePais(1);

                // ASSERT
                Assert.AreEqual(0, result.Result);
            }
        }

 

  Compartir


  Nuevo comentario

El campo Comentario es obligatorio.
El campo Nombre es obligatorio.

Enviando ...

  Comentarios

ASP ASP

Excelente articulo, lo felicito me ayudo mucho 👍💪 y esta bien explicado.


Rafael Acosta Administrador Rafael Acosta

@ASP:

Gracias por tu comentario. Compartir los artículos en redes sociales y foros de programación, es fundamental para que este Blog siga ofreciendo publicaciones de calidad y en español.



Perfil para Rafael Acosta en Stack Overflow en español, preguntas y respuestas para programadores y profesionales de la informática.

  Etiquetas

.NET Core .NET Framework .NET MVC .NET Standard AJAX ASP.NET ASP.NET Core ASP.NET MVC Bootstrap Buenas prácticas C# Cookies Entity Framework JavaScript jQuery JSON JWT PDF Pruebas Unitarias Seguridad SEO SOAP Sql Server SqLite Swagger Validación Web API Web Forms Web Services WYSIWYG

  Nuevos


  Populares















Utilizamos cookies propias y de terceros para mejorar nuestros servicios y ofrecerle una mejor experiencia de navegación. Si continúa navegando consideramos que acepta su uso. Más información   Acepto