Test avec vos propres doubles de test

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 de test

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 de test : cette approche implique l’écriture de votre propre implémentation en mémoire de votre contexte et DbSets. Cela vous permet d’exercer un contrôle important sur le comportement des classes, mais peut impliquer l’écriture et la possession 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 créer dynamiquement les implémentations en mémoire de votre contexte et de vos ensembles au moment de l’exécution.

Cet article traitera de la création de votre propre double de test. Pour plus d’informations sur l’utilisation d’une infrastructure de simulation, consultez Test avec une infrastructure de simulation.

Test avec les versions antérieures à EF6

Le code présenté dans cet article est compatible avec EF6. Pour les tests avec EF5 et version antérieure, consultez Test avec un contexte fictif.

Limites des doubles de test EF en mémoire

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. Le comportement peut donc être différent de celui obtenu en utilisant le fournisseur LINQ (LINQ to Entities) d’EF pour traduire les requêtes en langage SQL exécuté dans votre base de données.

Un exemple d’une telle différence est le chargement de données associées. Si vous créez une série de blogs comportant chacun des billets connexes, lorsque vous utilisez des données en mémoire, les billets associés seront 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.

Il est donc recommandé de toujours inclure un certain niveau de tests de bout en bout (en plus de vos tests unitaires) pour vous assurer que votre application fonctionne correctement avec 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 les suivre si vous le souhaitez. Il est plus simple de créer un Projet de test unitaire. Vous devrez cibler .NET Framework 4.5 pour terminer les sections qui utilisent async.

Création d’une interface contextuelle

Nous allons examiner le test d’un service qui utilise un modèle EF. Afin de pouvoir remplacer notre contexte EF par une version en mémoire pour les tests, nous allons définir une interface que notre contexte EF (et son double en mémoire) implémentera.

Le service que nous allons tester interrogera et modifiera les données à l’aide des propriétés DbSet de notre contexte et appellera également SaveChanges pour envoyer les modifications à la base de données. Nous incluons donc ces membres sur l’interface.

using System.Data.Entity;

namespace TestingDemo
{
    public interface IBloggingContext
    {
        DbSet<Blog> Blogs { get; }
        DbSet<Post> Posts { get; }
        int SaveChanges();
    }
}

Le modèle EF

Le service que nous allons tester utilise un modèle EF composé du BloggingContext et des classes 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, IBloggingContext
    {
        public DbSet<Blog> Blogs { get; set; }
        public 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; }
    }
}

Implémentation de l’interface contextuelle avec le Concepteur EF

Notez que notre contexte implémente l’interface IBloggingContext.

Si vous utilisez Code First, vous pouvez modifier votre contexte directement pour implémenter l’interface. Si vous utilisez le Concepteur EF, vous devrez 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 dans l’interface comme indiqué.

<#=Accessibility.ForType(container)#> partial class <#=code.Escape(container)#> : DbContext, IBloggingContext

Service à tester

Pour illustrer les tests avec les 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 renvoyer tous les blogs classés par nom (GetAllBlogs). En plus de GetAllBlogs, nous avons également fourni une méthode qui obtiendra de manière 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 IBloggingContext _context;

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

        public Blog AddBlog(string name, string url)
        {
            var blog = new Blog { Name = name, Url = url };
            _context.Blogs.Add(blog);
            _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();
        }
    }
}

Création des doubles de test en mémoire

Maintenant que nous avons le modèle EF réel et le service qui peut l’utiliser, il est temps de créer le double de test en mémoire que nous pouvons utiliser pour les tests. Nous avons créé un double de test TestContext pour notre contexte. Dans les doubles de test, nous pouvons choisir le comportement que nous voulons pour prendre en charge les tests que nous allons exécuter. Dans cet exemple, nous capturons simplement le nombre de fois où SaveChanges est appelé, mais vous pouvez inclure la logique nécessaire pour vérifier le scénario que vous testez.

Nous avons également créé un TestDbSet qui fournit une implémentation en mémoire de DbSet. Nous avons fourni une implémentation complète de toutes les méthodes de DbSet (à l’exception de Find), mais vous devez uniquement implémenter les membres que votre scénario de test utilisera.

TestDbSet utilise d’autres classes d’infrastructure que nous avons incluses pour s’assurer que les requêtes asynchrones peuvent être traitées.

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

namespace TestingDemo
{
    public class TestContext : IBloggingContext
    {
        public TestContext()
        {
            this.Blogs = new TestDbSet<Blog>();
            this.Posts = new TestDbSet<Post>();
        }

        public DbSet<Blog> Blogs { get; set; }
        public DbSet<Post> Posts { get; set; }
        public int SaveChangesCount { get; private set; }
        public int SaveChanges()
        {
            this.SaveChangesCount++;
            return 1;
        }
    }

    public class TestDbSet<TEntity> : DbSet<TEntity>, IQueryable, IEnumerable<TEntity>, IDbAsyncEnumerable<TEntity>
        where TEntity : class
    {
        ObservableCollection<TEntity> _data;
        IQueryable _query;

        public TestDbSet()
        {
            _data = new ObservableCollection<TEntity>();
            _query = _data.AsQueryable();
        }

        public override TEntity Add(TEntity item)
        {
            _data.Add(item);
            return item;
        }

        public override TEntity Remove(TEntity item)
        {
            _data.Remove(item);
            return item;
        }

        public override TEntity Attach(TEntity item)
        {
            _data.Add(item);
            return item;
        }

        public override TEntity Create()
        {
            return Activator.CreateInstance<TEntity>();
        }

        public override TDerivedEntity Create<TDerivedEntity>()
        {
            return Activator.CreateInstance<TDerivedEntity>();
        }

        public override ObservableCollection<TEntity> Local
        {
            get { return _data; }
        }

        Type IQueryable.ElementType
        {
            get { return _query.ElementType; }
        }

        Expression IQueryable.Expression
        {
            get { return _query.Expression; }
        }

        IQueryProvider IQueryable.Provider
        {
            get { return new TestDbAsyncQueryProvider<TEntity>(_query.Provider); }
        }

        System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
        {
            return _data.GetEnumerator();
        }

        IEnumerator<TEntity> IEnumerable<TEntity>.GetEnumerator()
        {
            return _data.GetEnumerator();
        }

        IDbAsyncEnumerator<TEntity> IDbAsyncEnumerable<TEntity>.GetAsyncEnumerator()
        {
            return new TestDbAsyncEnumerator<TEntity>(_data.GetEnumerator());
        }
    }

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

Implémentation de Find

La méthode Find est difficile à implémenter de manière générique. Si vous devez tester du code qui utilise la méthode Find, le plus simple consiste à créer un DbSet de test pour chacun des types d’entité qui doivent prendre en charge Find. Vous pouvez ensuite écrire une logique pour trouver ce type d’entité particulier, comme indiqué ci-dessous.

using System.Linq;

namespace TestingDemo
{
    class TestBlogDbSet : TestDbSet<Blog>
    {
        public override Blog Find(params object[] keyValues)
        {
            var id = (int)keyValues.Single();
            return this.SingleOrDefault(b => b.BlogId == id);
        }
    }
}

Écriture de quelques tests

C’est tout ce que nous avons à faire pour commencer les tests. Le test suivant crée un TestContext, puis un service basé sur ce contexte. Le service est ensuite utilisé pour créer un nouveau blog, à l’aide de la méthode AddBlog. Enfin, le test vérifie que le service a ajouté un nouveau blog à la propriété Blogs du contexte et qu’il a appelé SaveChanges sur le contexte.

Il s’agit simplement d’un exemple des types d’éléments que vous pouvez tester avec un double de test en mémoire et vous pouvez adapter la logique des doubles de test et la vérification pour répondre à vos besoins.

using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Linq;

namespace TestingDemo
{
    [TestClass]
    public class NonQueryTests
    {
        [TestMethod]
        public void CreateBlog_saves_a_blog_via_context()
        {
            var context = new TestContext();

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

            Assert.AreEqual(1, context.Blogs.Count());
            Assert.AreEqual("ADO.NET Blog", context.Blogs.Single().Name);
            Assert.AreEqual("http://blogs.msdn.com/adonet", context.Blogs.Single().Url);
            Assert.AreEqual(1, context.SaveChangesCount);
        }
    }
}

Voici un autre exemple de test : cette fois-ci, il s’agit d’une requête. Le test commence par la création d’un contexte de test avec certaines données dans sa propriété Blog. Notez que les données ne sont pas classées par ordre alphabétique. Nous pouvons ensuite créer un BlogService basé sur notre contexte de test et nous assurer que les données que nous récupérons à partir de GetAllBlogs sont classées par nom.

using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace TestingDemo
{
    [TestClass]
    public class QueryTests
    {
        [TestMethod]
        public void GetAllBlogs_orders_by_name()
        {
            var context = new TestContext();
            context.Blogs.Add(new Blog { Name = "BBB" });
            context.Blogs.Add(new Blog { Name = "ZZZ" });
            context.Blogs.Add(new Blog { Name = "AAA" });

            var service = new BlogService(context);
            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);
        }
    }
}

Enfin, nous allons écrire un test supplémentaire qui utilise notre méthode asynchrone pour nous assurer que l’infrastructure asynchrone que nous avons incluse dans TestDbSet fonctionne.

using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace TestingDemo
{
    [TestClass]
    public class AsyncQueryTests
    {
        [TestMethod]
        public async Task GetAllBlogsAsync_orders_by_name()
        {
            var context = new TestContext();
            context.Blogs.Add(new Blog { Name = "BBB" });
            context.Blogs.Add(new Blog { Name = "ZZZ" });
            context.Blogs.Add(new Blog { Name = "AAA" });

            var service = new BlogService(context);
            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);
        }
    }
}