Pruebas con un marco de trabajo ficticio

Nota:

Solo EF6 y versiones posteriores: las características, las API, etc. que se tratan en esta página se han incluido a partir de Entity Framework 6. Si usa una versión anterior, no se aplica parte o la totalidad de la información.

Cuando creamos pruebas para la aplicación, suele ser buena idea evitar consultar la base de datos. Entity Framework le permite hacerlo al crear un contexto que usa datos en memoria y cuyo comportamiento lo definen las pruebas.

Opciones para crear dobles de prueba

Hay dos enfoques diferentes que se pueden usar para crear una versión en memoria del contexto.

  • Crear sus propios dobles de prueba: este enfoque implica escribir su propia implementación en memoria del contexto y DbSets. Esto le proporciona un gran control sobre cómo se comportan las clases, pero puede implicar escribir y mantener muchas líneas de código.
  • Usar un marco de trabajo ficticio para crear dobles de prueba: con un marco ficticio (como Moq) puede tener las implementaciones en memoria del contexto y los conjuntos creados dinámicamente en tiempo de ejecución automáticamente.

En este artículo se tratará el uso de un marco ficticio. Para crear sus propios dobles de prueba, consulte Probar sus propios dobles de prueba.

Para demostrar el uso de EF con un marco ficticio, vamos a usar Moq. La manera más fácil de obtener Moq es instalar el paquete moq de desde NuGet.

Pruebas con versiones anteriores a EF6

El escenario que se muestra en este artículo depende de algunos cambios realizados en DbSet en EF6. Para realizar pruebas con EF5 y versiones anteriores, consulte Probar con un contexto falso.

Limitaciones de los dobles de prueba en memoria de EF

Los dobles de prueba en memoria pueden proporcionar una buena cobertura de nivel de prueba unitaria de bits de la aplicación que usan EF. Sin embargo, al hacerlo, se usa LINQ to Objects para ejecutar consultas en datos en memoria. Esto puede dar lugar a un comportamiento diferente del uso del proveedor LINQ de EF (LINQ to Entities) para traducir las consultas a SQL de ejecución en la base de datos.

Un ejemplo de esta diferencia es cargar datos relacionados. Si crea una serie de blogs con entradas relacionadas, al usar datos en memoria las entradas relacionadas siempre se cargarán para cada blog. Sin embargo, cuando se ejecuta en una base de datos, los datos solo se cargarán si usa el método Include.

Por este motivo, se recomienda incluir siempre algún nivel de pruebas de un extremo a otro (además de las pruebas unitarias) para asegurarse de que la aplicación funciona correctamente en una base de datos.

Código para seguir este artículo

En este artículo se proporcionan listas de código completas que puede copiar en Visual Studio para seguir el artículo si lo desea. Es más fácil crear un proyecto de prueba unitaria y tendrá que tener como destino .NET Framework 4.5 para completar las secciones asincrónicas.

El modelo de EF

El servicio que vamos a probar usa un modelo de EF formado por las clases Blog y Post y BloggingContext. El código puede haberlo generado el Diseñador de EF o ser un modelo de Code First.

using System.Collections.Generic;
using System.Data.Entity;

namespace TestingDemo
{
    public class BloggingContext : DbContext
    {
        public virtual DbSet<Blog> Blogs { get; set; }
        public virtual DbSet<Post> Posts { get; set; }
    }

    public class Blog
    {
        public int BlogId { get; set; }
        public string Name { get; set; }
        public string Url { get; set; }

        public virtual List<Post> Posts { get; set; }
    }

    public class Post
    {
        public int PostId { get; set; }
        public string Title { get; set; }
        public string Content { get; set; }

        public int BlogId { get; set; }
        public virtual Blog Blog { get; set; }
    }
}

Propiedades de DbSet virtual con EF Designer

Tenga en cuenta que las propiedades DbSet del contexto se marcan como virtuales. Esto permitirá que el marco de trabajo ficticio se derive de nuestro contexto e invalide estas propiedades con una implementación simulada.

Si usa Code First, puede editar las clases directamente. Si usa EF Designer, deberá editar la plantilla T4 que genera el contexto. Abra el archivo <model_name>.Context.tt que está anidado en el archivo edmx, busque el siguiente fragmento de código y agregue la palabra clave virtual como se muestra.

public string DbSet(EntitySet entitySet)
{
    return string.Format(
        CultureInfo.InvariantCulture,
        "{0} virtual DbSet\<{1}> {2} {{ get; set; }}",
        Accessibility.ForReadOnlyProperty(entitySet),
        _typeMapper.GetTypeName(entitySet.ElementType),
        _code.Escape(entitySet));
}

Servicio que se va a probar

Para demostrar las pruebas con dobles de prueba en memoria, vamos a escribir un par de pruebas para un BlogService. El servicio es capaz de crear nuevos blogs (AddBlog) y devolver todos los blogs ordenados por nombre (GetAllBlogs). Además de GetAllBlogs, también hemos proporcionado un método que obtendrá de forma asincrónica todos los blogs ordenados por nombre (GetAllBlogsAsync).

using System.Collections.Generic;
using System.Data.Entity;
using System.Linq;
using System.Threading.Tasks;

namespace TestingDemo
{
    public class BlogService
    {
        private BloggingContext _context;

        public BlogService(BloggingContext context)
        {
            _context = context;
        }

        public Blog AddBlog(string name, string url)
        {
            var blog = _context.Blogs.Add(new Blog { Name = name, Url = url });
            _context.SaveChanges();

            return blog;
        }

        public List<Blog> GetAllBlogs()
        {
            var query = from b in _context.Blogs
                        orderby b.Name
                        select b;

            return query.ToList();
        }

        public async Task<List<Blog>> GetAllBlogsAsync()
        {
            var query = from b in _context.Blogs
                        orderby b.Name
                        select b;

            return await query.ToListAsync();
        }
    }
}

Prueba de escenarios que no son de consulta

Eso es todo lo que debemos hacer para empezar a probar métodos que no son de consulta. En la prueba siguiente se usa Moq para crear un contexto. A continuación, crea DbSet<Blog> y lo envía a partir de la propiedad Blogs del contexto. A continuación, el contexto se usa para crear un nuevo BlogService, que se emplea para crear un nuevo blog mediante el método AddBlog. Por último, la prueba verifica que el servicio ha agregado un nuevo blog y llama SaveChanges en el contexto.

using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using System.Data.Entity;

namespace TestingDemo
{
    [TestClass]
    public class NonQueryTests
    {
        [TestMethod]
        public void CreateBlog_saves_a_blog_via_context()
        {
            var mockSet = new Mock<DbSet<Blog>>();

            var mockContext = new Mock<BloggingContext>();
            mockContext.Setup(m => m.Blogs).Returns(mockSet.Object);

            var service = new BlogService(mockContext.Object);
            service.AddBlog("ADO.NET Blog", "http://blogs.msdn.com/adonet");

            mockSet.Verify(m => m.Add(It.IsAny<Blog>()), Times.Once());
            mockContext.Verify(m => m.SaveChanges(), Times.Once());
        }
    }
}

Prueba de escenarios de consulta

Para poder ejecutar consultas en la prueba doble dbset, es necesario configurar una implementación de IQueryable. El primer paso es crear algunos datos en memoria: usaremos List<Blog>. A continuación, se crea un contexto y DBSet<Blog>. Luego, se conecta la implementación de IQueryable para el DbSet. Simplemente, se delega al proveedor LINQ to Objects que funciona con List<T>.

A continuación, podemos crear un BlogService basado en nuestros dobles de prueba y asegurarnos de que los datos que recuperamos de GetAllBlogs se ordenen por nombre.

using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using System.Collections.Generic;
using System.Data.Entity;
using System.Linq;

namespace TestingDemo
{
    [TestClass]
    public class QueryTests
    {
        [TestMethod]
        public void GetAllBlogs_orders_by_name()
        {
            var data = new List<Blog>
            {
                new Blog { Name = "BBB" },
                new Blog { Name = "ZZZ" },
                new Blog { Name = "AAA" },
            }.AsQueryable();

            var mockSet = new Mock<DbSet<Blog>>();
            mockSet.As<IQueryable<Blog>>().Setup(m => m.Provider).Returns(data.Provider);
            mockSet.As<IQueryable<Blog>>().Setup(m => m.Expression).Returns(data.Expression);
            mockSet.As<IQueryable<Blog>>().Setup(m => m.ElementType).Returns(data.ElementType);
            mockSet.As<IQueryable<Blog>>().Setup(m => m.GetEnumerator()).Returns(() => data.GetEnumerator());

            var mockContext = new Mock<BloggingContext>();
            mockContext.Setup(c => c.Blogs).Returns(mockSet.Object);

            var service = new BlogService(mockContext.Object);
            var blogs = service.GetAllBlogs();

            Assert.AreEqual(3, blogs.Count);
            Assert.AreEqual("AAA", blogs[0].Name);
            Assert.AreEqual("BBB", blogs[1].Name);
            Assert.AreEqual("ZZZ", blogs[2].Name);
        }
    }
}

Pruebas con consultas asincrónicas

Entity Framework 6 introdujo un conjunto de métodos de extensión que se pueden usar para ejecutar de forma asincrónica una consulta. Algunos ejemplos de estos métodos son ToListAsync, FirstAsync, ForEachAsync, etc.

Dado que las consultas de Entity Framework usan LINQ, los métodos de extensión se definen en IQueryable e IEnumerable. Sin embargo, dado que solo están diseñados para usarse con Entity Framework, puede recibir el siguiente error si intenta usarlos en una consulta LINQ que no sea una consulta de Entity Framework:

IQueryable de origen no implementa IDbAsyncEnumerable{0}. Solo los orígenes que implementan IDbAsyncEnumerable se pueden usar para las operaciones asincrónicas de Entity Framework. Para más información, consulte http://go.microsoft.com/fwlink/?LinkId=287068.

Aunque los métodos asincrónicos solo se admiten al ejecutarse en una consulta EF, es posible que quiera usarlos en la prueba unitaria al ejecutarse con un doble de prueba en memoria de un DbSet.

Para usar los métodos asincrónicos, es necesario crear un dbAsyncQueryProvider en memoria para procesar la consulta asincrónica. Aunque sería posible configurar un proveedor de consultas mediante Moq, es mucho más fácil crear una implementación doble de prueba en el código. El código de esta implementación es el siguiente:

using System.Collections.Generic;
using System.Data.Entity.Infrastructure;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;

namespace TestingDemo
{
    internal class TestDbAsyncQueryProvider<TEntity> : IDbAsyncQueryProvider
    {
        private readonly IQueryProvider _inner;

        internal TestDbAsyncQueryProvider(IQueryProvider inner)
        {
            _inner = inner;
        }

        public IQueryable CreateQuery(Expression expression)
        {
            return new TestDbAsyncEnumerable<TEntity>(expression);
        }

        public IQueryable<TElement> CreateQuery<TElement>(Expression expression)
        {
            return new TestDbAsyncEnumerable<TElement>(expression);
        }

        public object Execute(Expression expression)
        {
            return _inner.Execute(expression);
        }

        public TResult Execute<TResult>(Expression expression)
        {
            return _inner.Execute<TResult>(expression);
        }

        public Task<object> ExecuteAsync(Expression expression, CancellationToken cancellationToken)
        {
            return Task.FromResult(Execute(expression));
        }

        public Task<TResult> ExecuteAsync<TResult>(Expression expression, CancellationToken cancellationToken)
        {
            return Task.FromResult(Execute<TResult>(expression));
        }
    }

    internal class TestDbAsyncEnumerable<T> : EnumerableQuery<T>, IDbAsyncEnumerable<T>, IQueryable<T>
    {
        public TestDbAsyncEnumerable(IEnumerable<T> enumerable)
            : base(enumerable)
        { }

        public TestDbAsyncEnumerable(Expression expression)
            : base(expression)
        { }

        public IDbAsyncEnumerator<T> GetAsyncEnumerator()
        {
            return new TestDbAsyncEnumerator<T>(this.AsEnumerable().GetEnumerator());
        }

        IDbAsyncEnumerator IDbAsyncEnumerable.GetAsyncEnumerator()
        {
            return GetAsyncEnumerator();
        }

        IQueryProvider IQueryable.Provider
        {
            get { return new TestDbAsyncQueryProvider<T>(this); }
        }
    }

    internal class TestDbAsyncEnumerator<T> : IDbAsyncEnumerator<T>
    {
        private readonly IEnumerator<T> _inner;

        public TestDbAsyncEnumerator(IEnumerator<T> inner)
        {
            _inner = inner;
        }

        public void Dispose()
        {
            _inner.Dispose();
        }

        public Task<bool> MoveNextAsync(CancellationToken cancellationToken)
        {
            return Task.FromResult(_inner.MoveNext());
        }

        public T Current
        {
            get { return _inner.Current; }
        }

        object IDbAsyncEnumerator.Current
        {
            get { return Current; }
        }
    }
}

Ahora que tenemos un proveedor de consultas asincrónico, podemos escribir una prueba unitaria para nuestro nuevo método GetAllBlogsAsync.

using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using System.Collections.Generic;
using System.Data.Entity;
using System.Data.Entity.Infrastructure;
using System.Linq;
using System.Threading.Tasks;

namespace TestingDemo
{
    [TestClass]
    public class AsyncQueryTests
    {
        [TestMethod]
        public async Task GetAllBlogsAsync_orders_by_name()
        {

            var data = new List<Blog>
            {
                new Blog { Name = "BBB" },
                new Blog { Name = "ZZZ" },
                new Blog { Name = "AAA" },
            }.AsQueryable();

            var mockSet = new Mock<DbSet<Blog>>();
            mockSet.As<IDbAsyncEnumerable<Blog>>()
                .Setup(m => m.GetAsyncEnumerator())
                .Returns(new TestDbAsyncEnumerator<Blog>(data.GetEnumerator()));

            mockSet.As<IQueryable<Blog>>()
                .Setup(m => m.Provider)
                .Returns(new TestDbAsyncQueryProvider<Blog>(data.Provider));

            mockSet.As<IQueryable<Blog>>().Setup(m => m.Expression).Returns(data.Expression);
            mockSet.As<IQueryable<Blog>>().Setup(m => m.ElementType).Returns(data.ElementType);
            mockSet.As<IQueryable<Blog>>().Setup(m => m.GetEnumerator()).Returns(() => data.GetEnumerator());

            var mockContext = new Mock<BloggingContext>();
            mockContext.Setup(c => c.Blogs).Returns(mockSet.Object);

            var service = new BlogService(mockContext.Object);
            var blogs = await service.GetAllBlogsAsync();

            Assert.AreEqual(3, blogs.Count);
            Assert.AreEqual("AAA", blogs[0].Name);
            Assert.AreEqual("BBB", blogs[1].Name);
            Assert.AreEqual("ZZZ", blogs[2].Name);
        }
    }
}