Testning med ett mock-ramverk

Anmärkning

ENDAST EF6 – De funktioner, API:er osv. som beskrivs på den här sidan introducerades i Entity Framework 6. Om du använder en tidigare version gäller inte en del av eller all information.

När du skriver tester för ditt program är det ofta önskvärt att undvika att träffa databasen. Med Entity Framework kan du uppnå detta genom att skapa en kontext – med beteende som definieras av dina tester – som använder minnesintern data.

Alternativ för att skapa testdubblar

Det finns två olika metoder som kan användas för att skapa en minnesintern version av kontexten.

  • Skapa dina egna testdubblar – Den här metoden innebär att du skriver en egen minnesintern implementering av kontexten och DbSets. Detta ger dig stor kontroll över hur klasserna beter sig men kan innebära att skriva och äga en rimlig mängd kod.
  • Använd ett mockningsramverk för att skapa testdubblar – Med ett mockningsramverk (till exempel Moq) kan du få implementeringar i minnet av din kontext och samlingar skapade dynamiskt vid körning åt dig.

Den här artikeln handlar om att använda ett hånfullt ramverk. Information om hur du skapar dina egna testdubblar finns i Testa med dina egna testdubblar.

För att demonstrera användningen av EF med ett hånfullt ramverk ska vi använda Moq. Det enklaste sättet att hämta Moq är att installera Moq-paketet från NuGet.

Testa med förhandsversioner av EF6

Scenariot som visas i den här artikeln är beroende av vissa ändringar som vi har gjort i DbSet i EF6. För testning med EF5 och tidigare version, se Testa med en falsk kontext.

Begränsningar för EF-minnesinternt test fördubblas

Minnesbaserade testdubblar kan vara ett bra sätt att ge enhetstesttäckning för delar av din applikation som använder Entity Framework. Men när du gör detta använder du LINQ till Objekt för att köra frågor mot minnesintern data. Detta kan leda till ett annat beteende än att använda EF:s LINQ-provider (LINQ till entiteter) för att översätta frågor till SQL som körs mot din databas.

Ett exempel på en sådan skillnad är att läsa in relaterade data. Om du skapar en serie bloggar som var och en har relaterade inlägg, kommer relaterade inlägg alltid att läsas in för varje blogg när du använder minnesintern data. Men när du kör mot en databas läses data bara in om du använder metoden Inkludera.

Därför rekommenderar vi att du alltid inkluderar en viss nivå av testning från slutpunkt till slutpunkt (utöver dina enhetstester) för att säkerställa att programmet fungerar korrekt mot en databas.

Följ med i den här artikeln

Den här artikeln innehåller fullständiga kodlistor som du kan kopiera till Visual Studio för att följa med om du vill. Det är enklast att skapa ett enhetstestprojekt och du måste rikta in dig på .NET Framework 4.5 för att slutföra de avsnitt som använder asynkronisering.

EF-modellen

Tjänsten vi ska testa använder en EF-modell som består av bloggingContext- och blogg- och postklasserna. Den här koden kan ha genererats av EF Designer eller vara en Code First-modell.

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

Virtual DbSet-egenskaper med EF Designer

Observera att DbSet-egenskaperna i kontexten är markerade som virtuella. Detta möjliggör att mockningsramverket kan hämta från vår kontext och åsidosätta dessa egenskaper med en simulerad implementering.

Om du använder Kod först kan du redigera dina klasser direkt. Om du använder EF Designer måste du redigera T4-mallen som genererar kontexten. Öppna filen <model_name.Context.tt> som är kapslad under edmx-filen, leta upp följande kodfragment och lägg till det virtuella nyckelordet som visas.

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

Tjänst som ska testas

För att demonstrera testning med minnesintern testdubblar kommer vi att skriva ett par tester för en BlogService. Tjänsten kan skapa nya bloggar (AddBlog) och returnera alla bloggar ordnade efter namn (GetAllBlogs). Förutom GetAllBlogs har vi även tillhandahållit en metod som asynkront får alla bloggar ordnade efter namn (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();
        }
    }
}

Testa scenarier som inte är frågescenarier

Det är allt vi behöver göra för att börja testa icke-frågemetoder. I följande test används Moq för att skapa en kontext. Den skapar sedan en DbSet-blogg<> och kopplar den så att den returneras från kontextens bloggegenskap. Därefter används kontexten för att skapa en ny BlogService som sedan används för att skapa en ny blogg – med metoden AddBlog. Slutligen verifierar testet att tjänsten har lagt till en ny blogg och heter SaveChanges i kontexten.

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

Testa frågescenarier

För att kunna köra frågor mot vår DbSet-testdubblett måste vi konfigurera en implementering av IQueryable. Det första steget är att skapa en del minnesinterna data – vi använder en lista med <Bloggar>. Därefter skapar vi en kontext och DbSet<Blog> och kopplar sedan upp IQueryable-implementeringen för DbSet – de delegerar bara till LINQ to Objects-providern som fungerar med List<T>.

Vi kan sedan skapa en BlogService baserat på våra testdubblar och se till att de data vi får tillbaka från GetAllBlogs sorteras efter namn.

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

Testa med asynkrona frågor

Entity Framework 6 introducerade en uppsättning tilläggsmetoder som kan användas för att asynkront köra en fråga. Exempel på dessa metoder är ToListAsync, FirstAsync, ForEachAsync osv.

Eftersom Entity Framework-frågor använder LINQ definieras tilläggsmetoderna för IQueryable och IEnumerable. Men eftersom de bara är utformade för att användas med Entity Framework kan du få följande fel om du försöker använda dem på en LINQ-fråga som inte är en Entity Framework-fråga:

Käll-IQueryable implementerar inte IDbAsyncEnumerable{0}. Endast källor som implementerar IDbAsyncEnumerable kan användas för asynkrona åtgärder i Entity Framework. Mer information finns i: http://go.microsoft.com/fwlink/?LinkId=287068.

Även om asynkrona metoder endast stöds när de körs mot en EF-fråga, kanske du vill använda dem i enhetstestet när du kör mot en minnesintern testdubblett av en DbSet.

För att kunna använda asynkrona metoder måste vi skapa en minnesintern DbAsyncQueryProvider för att bearbeta asynkron fråga. Även om det skulle vara möjligt att konfigurera en frågetjänst med hjälp av Moq är det betydligt enklare att skapa en testdubbelsimplementering i kod. Koden för den här implementeringen är följande:

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

Nu när vi har en asynkron frågeprovider kan vi skriva ett enhetstest för vår nya GetAllBlogsAsync-metod.

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