Testování s použitím vlastních testovacích doubles

Poznámka

Pouze EF6 a novější – Funkce, rozhraní API atd. popsané na této stránce byly představeny v Entity Framework 6. Pokud používáte starší verzi, některé nebo všechny informace nemusí být platné.

Při psaní testů pro vaši aplikaci je často žádoucí vyhnout se dosažení databáze. Entity Framework vám to umožní dosáhnout vytvořením kontextu s chováním definovaným vašimi testy, které využívá data v paměti.

Možnosti pro vytváření testovacích dvojitých testů

Existují dva různé přístupy, které lze použít k vytvoření verze vašeho kontextu v paměti.

  • Vytvoření vlastního testovacího doubles – tento přístup zahrnuje psaní vlastní implementace kontextu a dbset v paměti. To vám dává velkou kontrolu nad chováním tříd, ale může zahrnovat psaní a vlastnictví přiměřeného množství kódu.
  • Pomocí napodobovací architektury můžete vytvořit testovací dvojité testy – pomocí napodobovací architektury (například Moq) můžete mít implementace kontextu v paměti a sady vytvořené dynamicky za běhu za vás.

Tento článek se zabývá vytvořením vlastního testu dvakrát. Informace o použití napodobování architektury naleznete v tématu Testování pomocí napodobování Framework.

Testování s využitím verzí EF6

Kód uvedený v tomto článku je kompatibilní s EF6. Testování pomocí EF5 a starší verze najdete v tématu Testování s falešným kontextem.

Omezení testů EF v paměti se zdvojnásobí

Dvojité testy v paměti mohou být dobrým způsobem, jak zajistit pokrytí úrovně testování jednotek bitů vaší aplikace, které používají EF. Když to ale uděláte, používáte LINQ to Objects ke spouštění dotazů na data v paměti. To může mít za následek jiné chování než použití zprostředkovatele LINQ (LINQ to Entities) EF k překladu dotazů do SQL, které se spouští ve vaší databázi.

Jedním z příkladů takového rozdílu je načítání souvisejících dat. Pokud vytvoříte řadu blogů, které mají všechny související příspěvky, při použití dat v paměti budou související příspěvky vždy načteny pro každý blog. Při spuštění v databázi se však data načtou pouze v případě, že použijete metodu Include.

Z tohoto důvodu doporučujeme vždy zahrnout určitou úroveň kompletního testování (kromě testů jednotek), aby vaše aplikace fungovala správně s databází.

Podle pokynů v tomto článku

Tento článek obsahuje kompletní výpisy kódu, které můžete zkopírovat do sady Visual Studio, abyste postupovali podle potřeby. Nejjednodušší je vytvořit projekt testování jednotek a budete muset cílit na rozhraní .NET Framework 4.5 , abyste dokončili oddíly, které používají asynchronní.

Vytvoření kontextového rozhraní

Podíváme se na testování služby, která využívá model EF. Abychom mohli nahradit kontext EF verzí v paměti pro testování, definujeme rozhraní, které bude implementovat náš kontext EF (a je v paměti double).

Služba, která budeme testovat, bude dotazovat a upravovat data pomocí vlastností DbSet našeho kontextu a také volat SaveChanges pro nasdílení změn do databáze. Proto do rozhraní zahrneme tyto členy.

using System.Data.Entity;

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

Model EF

Služba, která budeme testovat, využívá model EF tvořený třídami BloggingContext a Blog a Post. Tento kód mohl vygenerovat ef Designer nebo být modelem 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; }
    }
}

Implementace kontextového rozhraní pomocí nástroje EF Designer

Všimněte si, že náš kontext implementuje IBloggingContext rozhraní.

Pokud používáte Code First, můžete upravit kontext přímo pro implementaci rozhraní. Pokud používáte EF Designer, budete muset upravit šablonu T4, která generuje váš kontext. <Otevřete soubor model_name.Context.tt>, který je vnořený pod souborem edmx, vyhledejte následující fragment kódu a přidejte ho do rozhraní, jak je znázorněno.

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

Služba, která se má testovat

Abychom si ukázali testování s dvojitým testem v paměti, budeme psát několik testů pro Službu BlogService. Služba je schopná vytvářet nové blogy (AddBlog) a vracet všechny blogy seřazené podle názvu (GetAllBlogs). Kromě GetAllBlogs jsme také poskytli metodu, která asynchronně získá všechny blogy seřazené podle názvu (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();
        }
    }
}

Vytvoření dvojitého testu v paměti

Teď, když máme skutečný model EF a službu, která ho může používat, je čas vytvořit test v paměti, který můžeme použít k testování. Pro náš kontext jsme vytvořili test TestContext dvakrát. Ve dvou testech se dostaneme k tomu, abychom zvolili chování, které chceme, aby podporovaly testy, které budeme spouštět. V tomto příkladu právě zaznamenáváme počet volání SaveChanges, ale můžete zahrnout jakoukoli logiku potřebnou k ověření scénáře, který testujete.

Vytvořili jsme také Sadu TestDbSet, která poskytuje implementaci DbSet v paměti. Poskytli jsme úplnou implementaci pro všechny metody v DbSet (s výjimkou Find), ale potřebujete implementovat pouze členy, které bude váš testovací scénář používat.

Sada TestDbSet využívá některé další třídy infrastruktury, které jsme zahrnuli, abychom zajistili, že je možné zpracovávat asynchronní dotazy.

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

Implementace funkce Najít

Metoda Find se obtížně implementuje obecným způsobem. Pokud potřebujete testovat kód, který využívá metodu Find, je nejjednodušší vytvořit testovací DbSet pro každý typ entity, který potřebuje podporovat hledání. Pak můžete napsat logiku, která najde konkrétní typ entity, jak je znázorněno níže.

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

Psaní některých testů

To je vše, co musíme udělat, abychom mohli začít testovat. Následující test vytvoří TestContext a pak službu založenou na tomto kontextu. Služba se pak použije k vytvoření nového blogu – pomocí metody AddBlog. Nakonec test ověří, že služba přidala nový blog do vlastnosti Blogs kontextu a nazývá SaveChanges v kontextu.

Toto je jen příklad typů věcí, které můžete testovat s dvojitým testem v paměti, a můžete upravit logiku testu doubles a ověření tak, aby splňovalo vaše požadavky.

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

Tady je další příklad testu – tentokrát ten, který provádí dotaz. Test začíná vytvořením kontextu testu s některými daty ve vlastnosti blogu – všimněte si, že data nejsou v abecedním pořadí. Pak můžeme vytvořit službu BlogService na základě našeho testovacího kontextu a zajistit, aby data, která získáme zpět z GetAllBlogs, byla seřazena podle názvu.

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

Nakonec napíšeme ještě jeden test, který používá naši asynchronní metodu, abychom zajistili, že asynchronní infrastruktura, kterou jsme zahrnuli do sady TestDbSet , funguje.

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