Test avec une infrastructure fictive

Remarque

EF6 et versions ultérieures uniquement : Les fonctionnalités, les API, etc. décrites dans cette page ont été introduites dans Entity Framework 6. Si vous utilisez une version antérieure, certaines ou toutes les informations ne s’appliquent pas.

Lorsque vous écrivez des tests pour votre application, il est souvent souhaitable d’éviter d’atteindre la base de données. Entity Framework vous permet d’y parvenir en créant un contexte , avec un comportement défini par vos tests, qui utilise des données en mémoire.

Options de création de doubles tests

Il existe deux approches différentes qui peuvent être utilisées pour créer une version en mémoire de votre contexte.

  • Créez vos propres doubles tests : cette approche implique l’écriture de votre propre implémentation en mémoire de votre contexte et DbSets. Cela vous donne beaucoup de contrôle sur le comportement des classes, mais peut impliquer l’écriture et la propriété d’une quantité raisonnable de code.
  • Utilisez une infrastructure de simulation pour créer des doubles de test : à l’aide d’une infrastructure de simulation (telle que Moq), vous pouvez avoir les implémentations en mémoire de votre contexte et des jeux créés dynamiquement au moment de l’exécution pour vous.

Cet article traite de l’utilisation d’un framework fictif. Pour créer vos propres doubles de test, consultez Test avec vos propres doubles de test.

Pour illustrer l’utilisation d’EF avec un framework fictif, nous allons utiliser Moq. Le moyen le plus simple d’obtenir Moq consiste à installer le package Moq à partir de NuGet.

Test avec les versions antérieures à EF6

Le scénario présenté dans cet article dépend de certaines modifications apportées à DbSet dans EF6. Pour les tests avec EF5 et version antérieure, consultez Test avec un contexte fake.

Limitations du test ef en mémoire double

Les doubles tests en mémoire peuvent être un bon moyen de fournir une couverture de niveau de test unitaire des bits de votre application qui utilisent EF. Toutefois, lors de cette opération, vous utilisez LINQ to Objects pour exécuter des requêtes sur des données en mémoire. Cela peut entraîner un comportement différent de l’utilisation du fournisseur LINQ (LINQ to Entities) d’EF pour traduire des requêtes en SQL exécutées sur votre base de données.

Un exemple de telle différence consiste à charger des données associées. Si vous créez une série de blogs qui ont chacun des billets associés, lorsque vous utilisez des données en mémoire, les billets associés sont toujours chargés pour chaque blog. Toutefois, lors de l’exécution sur une base de données, les données ne seront chargées que si vous utilisez la méthode Include.

Pour cette raison, il est recommandé d’inclure toujours un niveau de test de bout en bout (en plus de vos tests unitaires) pour vous assurer que votre application fonctionne correctement sur une base de données.

Suite à cet article

Cet article fournit des listes de code complètes que vous pouvez copier dans Visual Studio pour suivre le suivi si vous le souhaitez. Il est plus simple de créer un projet de test unitaire et vous devez cibler .NET Framework 4.5 pour terminer les sections qui utilisent async.

Modèle EF

Le service que nous allons tester utilise un modèle EF composé des classes BlogContext et Blog et Post. Ce code peut avoir été généré par le Concepteur EF ou être un modèle 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; }
    }
}

Propriétés Virtual DbSet avec EF Designer

Notez que les propriétés DbSet sur le contexte sont marquées comme virtuelles. Cela permettra au framework de simulation de dériver de notre contexte et de remplacer ces propriétés par une implémentation simulée.

Si vous utilisez Code First, vous pouvez modifier vos classes directement. Si vous utilisez ef Designer, vous devez modifier le modèle T4 qui génère votre contexte. Ouvrez le <fichier model_name.Context.tt>imbriqué sous votre fichier edmx, recherchez le fragment de code suivant et ajoutez le mot clé virtuel comme indiqué.

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));
}

Service à tester

Pour illustrer les tests avec des doubles de test en mémoire, nous allons écrire quelques tests pour un BlogService. Le service est capable de créer de nouveaux blogs (AddBlog) et de retourner tous les blogs classés par nom (GetAllBlogs). En plus de GetAllBlogs, nous avons également fourni une méthode qui obtiendra de façon asynchrone tous les blogs classés par nom (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();
        }
    }
}

Test de scénarios sans requête

C’est tout ce que nous devons faire pour commencer à tester des méthodes non-requête. Le test suivant utilise Moq pour créer un contexte. Il crée ensuite un <blog> DbSet et le relie à retourner à partir de la propriété Blogs du contexte. Ensuite, le contexte est utilisé pour créer un blogService qui est ensuite utilisé pour créer un blog à l’aide de la méthode AddBlog. Enfin, le test vérifie que le service a ajouté un nouveau blog et appelé SaveChanges dans le contexte.

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());
        }
    }
}

Scénarios de requête de test

Pour pouvoir exécuter des requêtes sur notre double de test DbSet, nous devons configurer une implémentation d’IQueryable. La première étape consiste à créer des données en mémoire : nous utilisons un< blog de liste>. Ensuite, nous créons un contexte et un <blog> DBSet , puis connectons l’implémentation IQueryable pour dbSet . Ils sont simplement délégués au fournisseur LINQ to Objects qui fonctionne avec List<T>.

Nous pouvons ensuite créer un BlogService basé sur nos doubles de test et vous assurer que les données que nous récupérons à partir de GetAllBlogs sont classées par nom.

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);
        }
    }
}

Test avec des requêtes asynchrones

Entity Framework 6 a introduit un ensemble de méthodes d’extension qui peuvent être utilisées pour exécuter de façon asynchrone une requête. Parmi ces méthodes, citons ToListAsync, FirstAsync, ForEachAsync, etc.

Étant donné que les requêtes Entity Framework utilisent LINQ, les méthodes d’extension sont définies sur IQueryable et IEnumerable. Toutefois, étant donné qu’elles sont conçues uniquement pour être utilisées avec Entity Framework, vous pouvez recevoir l’erreur suivante si vous essayez de les utiliser sur une requête LINQ qui n’est pas une requête Entity Framework :

IQueryable source n’implémente pas IDbAsyncEnumerable{0}. Seules les sources qui implémentent IDbAsyncEnumerable peuvent être utilisées pour les opérations asynchrones Entity Framework. Pour plus d’informations, consultez http://go.microsoft.com/fwlink/?LinkId=287068.

Bien que les méthodes asynchrones soient uniquement prises en charge lors de l’exécution sur une requête EF, vous pouvez les utiliser dans votre test unitaire lors de l’exécution sur un double de test en mémoire d’un DbSet.

Pour utiliser les méthodes asynchrones, nous devons créer un dbAsyncQueryProvider en mémoire pour traiter la requête asynchrone. Bien qu’il soit possible de configurer un fournisseur de requêtes à l’aide de Moq, il est beaucoup plus facile de créer une implémentation double de test dans le code. Le code de cette implémentation est le suivant :

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; }
        }
    }
}

Maintenant que nous avons un fournisseur de requêtes asynchrone, nous pouvons écrire un test unitaire pour notre nouvelle méthode 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);
        }
    }
}