Testování pomocí napodobované architektury

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á použitím napodobování architektury. Pokud chcete vytvořit vlastní testovací doubles, přečtěte si téma Testování s vlastními testovacími doubles.

Abychom si ukázali použití EF s napodobenou architekturou, použijeme Moq. Nejjednodušší způsob, jak získat Moq, je nainstalovat balíček Moq z NuGetu.

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

Scénář uvedený v tomto článku závisí na některých změnách, které jsme provedli v dbSet v 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í.

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

Vlastnosti sady virtuálních databází pomocí nástroje EF Designer

Všimněte si, že vlastnosti DbSet v kontextu jsou označené jako virtuální. To umožní rozhraní napodobování odvodit z našeho kontextu a přepsání těchto vlastností pomocí napodobené implementace.

Pokud používáte Code First, můžete předměty upravovat přímo. 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ý do souboru edmx, vyhledejte následující fragment kódu a přidejte ho do virtuálního klíčového slova, jak je znázorněno.

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

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

Testování scénářů, které nejsou dotazy

To je vše, co musíme udělat, abychom mohli začít testovat metody bez dotazu. Následující test používá Moq k vytvoření kontextu. Pak vytvoří DbSet<Blog> a vytvoří ho, aby se vrátil z vlastnosti Blogs kontextu. Dále se kontext použije k vytvoření nové služby BlogService, která se pak použije k vytvoření nového blogu – pomocí metody AddBlog. Nakonec test ověří, že služba přidala nový blog a v kontextu se nazývá SaveChanges.

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

Testování scénářů dotazů

Aby bylo možné spouštět dotazy na náš test DbSet, musíme nastavit implementaci IQueryable. Prvním krokem je vytvoření některých dat v paměti – používáme blog> seznamu<. Dále vytvoříme kontext a blog> DBSet<pak vytvoříme implementaci IQueryable pro DbSet – právě delegují na zprostředkovatele LINQ to Objects, který funguje se seznamem<T>.

Pak můžeme vytvořit službu BlogService na základě našeho testu doubles a zajistit, aby data, která získáme zpět z GetAllBlogs, byla seřazena podle názvu.

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

Testování pomocí asynchronních dotazů

Entity Framework 6 zavedl sadu rozšiřujících metod, které lze použít k asynchronnímu spuštění dotazu. Mezi příklady těchto metod patří ToListAsync, FirstAsync, ForEachAsync atd.

Vzhledem k tomu, že dotazy Entity Framework využívají LINQ, jsou metody rozšíření definovány v IQueryable a IEnumerable. Vzhledem k tomu, že jsou navržené pouze pro použití s Rozhraním Entity Framework, může se zobrazit následující chyba, pokud se je pokusíte použít v dotazu LINQ, který není dotazem Entity Framework:

Zdrojový IQueryable neimplementuje IDbAsyncEnumerable{0}. Pro asynchronní operace Entity Framework lze použít pouze zdroje, které implementují IDbAsyncEnumerable. Další podrobnosti najdete tady: http://go.microsoft.com/fwlink/?LinkId=287068.

I když jsou asynchronní metody podporovány pouze při spuštění s dotazem EF, můžete je použít v testu jednotek při spuštění proti testu v paměti double dbSet.

Abychom mohli použít asynchronní metody, musíme vytvořit dbAsyncQueryProvider v paměti pro zpracování asynchronního dotazu. I když by bylo možné nastavit zprostředkovatele dotazů pomocí Moq, je mnohem jednodušší vytvořit testovací dvojitou implementaci v kódu. Kód pro tuto implementaci je následující:

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

Teď, když máme zprostředkovatele asynchronních dotazů, můžeme napsat test jednotek pro naši novou metodu 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);
        }
    }
}