Test con test double personalizzati
Nota
Solo EF6 e versioni successive: funzionalità, API e altri argomenti discussi in questa pagina sono stati introdotti in Entity Framework 6. Se si usa una versione precedente, le informazioni qui riportate, o parte di esse, non sono applicabili.
Quando si scrivono test per l'applicazione, è spesso consigliabile evitare di raggiungere il database. Entity Framework consente di ottenere questo risultato creando un contesto, con un comportamento definito dai test, che usa i dati in memoria.
Opzioni per la creazione di double di test
Esistono due approcci diversi che possono essere usati per creare una versione in memoria del contesto.
- Creare due test personalizzati: questo approccio prevede la scrittura di un'implementazione in memoria personalizzata del contesto e dei DbSet. In questo modo è possibile controllare il comportamento delle classi, ma può comportare la scrittura e la proprietà di una quantità ragionevole di codice.
- Usare un framework fittizio per creare double di test: usando un framework fittizio ,ad esempio Moq, è possibile avere le implementazioni in memoria del contesto e i set creati in modo dinamico in fase di esecuzione.
Questo articolo illustra come creare il proprio test double. Per informazioni sull'uso di un framework fittizio, vedere Test con un framework fittizio.
Test con versioni precedenti a EF6
Il codice illustrato in questo articolo è compatibile con EF6. Per i test con EF5 e la versione precedente, vedere Test con un contesto falso.
Limitazioni dei doppi test in memoria di Entity Framework
I valori double dei test in memoria possono essere un buon modo per fornire la copertura del livello di unit test dei bit dell'applicazione che usano Entity Framework. Tuttavia, quando si usa LINQ to Objects per eseguire query su dati in memoria. Questo può comportare un comportamento diverso rispetto all'uso del provider LINQ di EF (LINQ to Entities) per convertire le query in SQL eseguite nel database.
Un esempio di tale differenza è il caricamento di dati correlati. Se crei una serie di blog con post correlati, quando usi i dati in memoria, i post correlati verranno sempre caricati per ogni blog. Tuttavia, quando si esegue su un database, i dati verranno caricati solo se si utilizza il metodo Include.
Per questo motivo, è consigliabile includere sempre un certo livello di test end-to-end (oltre agli unit test) per garantire che l'applicazione funzioni correttamente su un database.
Seguendo questo articolo
Questo articolo fornisce elenchi di codice completi che è possibile copiare in Visual Studio per seguire la procedura, se lo si desidera. È più semplice creare un progetto unit test ed è necessario specificare come destinazione .NET Framework 4.5 per completare le sezioni che usano async.
Creazione di un'interfaccia di contesto
Si esaminerà il test di un servizio che usa un modello di Entity Framework. Per poter sostituire il contesto di Entity Framework con una versione in memoria per il test, verrà definita un'interfaccia che verrà implementata dal contesto ef (e che è in memoria doppia).
Il servizio che verrà testato eseguirà query e modificherà i dati usando le proprietà DbSet del contesto e chiamerà anche SaveChanges per eseguire il push delle modifiche nel database. Quindi stiamo includendo questi membri nell'interfaccia.
using System.Data.Entity;
namespace TestingDemo
{
public interface IBloggingContext
{
DbSet<Blog> Blogs { get; }
DbSet<Post> Posts { get; }
int SaveChanges();
}
}
Modello di Entity Framework
Il servizio che verrà testato usa un modello ef costituito dalle classi BloggingContext e Blog e Post. Questo codice potrebbe essere stato generato da Ef Designer o essere un modello 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; }
}
}
Implementazione dell'interfaccia di contesto con Entity Framework Designer
Si noti che il contesto implementa l'interfaccia IBloggingContext.
Se si usa Code First, è possibile modificare il contesto direttamente per implementare l'interfaccia. Se si usa Entity Framework Designer, sarà necessario modificare il modello T4 che genera il contesto. Aprire il <file model_name.Context.tt> annidato nel file edmx, trovare il frammento di codice seguente e aggiungere nell'interfaccia come illustrato.
<#=Accessibility.ForType(container)#> partial class <#=code.Escape(container)#> : DbContext, IBloggingContext
Servizio da testare
Per illustrare i test con i doppi test in memoria, verrà scritto un paio di test per un BlogService. Il servizio è in grado di creare nuovi blog (AddBlog) e restituire tutti i blog ordinati in base al nome (GetAllBlogs). Oltre a GetAllBlogs, è stato fornito anche un metodo che otterrà in modo asincrono tutti i blog ordinati in base al nome (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();
}
}
}
Creazione del doppio test in memoria
Ora che è disponibile il modello EF reale e il servizio che può usarlo, è possibile creare il doppio test in memoria che è possibile usare per i test. È stato creato un test TestContext double per il contesto. Nel test raddoppia la scelta del comportamento desiderato per supportare i test che verranno eseguiti. In questo esempio viene appena acquisito il numero di chiamate a SaveChanges, ma è possibile includere qualsiasi logica necessaria per verificare lo scenario di cui si sta eseguendo il test.
È stato creato anche un oggetto TestDbSet che fornisce un'implementazione in memoria di DbSet. È stata fornita un'implementazione completa per tutti i metodi in DbSet (ad eccezione di Find), ma è sufficiente implementare i membri che verranno usati dallo scenario di test.
TestDbSet usa alcune altre classi di infrastruttura incluse per garantire che le query asincrone possano essere elaborate.
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; }
}
}
}
Implementazione di Trova
Il metodo Find è difficile da implementare in modo generico. Se è necessario testare il codice che usa il metodo Find, è più semplice creare un DbSet di test per ognuno dei tipi di entità che devono supportare la ricerca. È quindi possibile scrivere la logica per trovare quel particolare tipo di entità, come illustrato di seguito.
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);
}
}
}
Scrittura di alcuni test
Questo è tutto quello che dobbiamo fare per avviare i test. Il test seguente crea un oggetto TestContext e quindi un servizio basato su questo contesto. Il servizio viene quindi usato per creare un nuovo blog, usando il metodo AddBlog. Infine, il test verifica che il servizio ha aggiunto un nuovo blog alla proprietà Blogs del contesto e chiamato SaveChanges nel contesto.
Questo è solo un esempio dei tipi di elementi che è possibile testare con un test in memoria double ed è possibile modificare la logica dei doppi test e la verifica per soddisfare i requisiti.
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);
}
}
}
Ecco un altro esempio di test: questa volta che esegue una query. Il test inizia creando un contesto di test con alcuni dati nella relativa proprietà Blog. Si noti che i dati non sono in ordine alfabetico. È quindi possibile creare un BlogService in base al contesto di test e assicurarsi che i dati restituiti da GetAllBlogs vengano ordinati in base al nome.
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);
}
}
}
Infine, si scriverà un altro test che usa il metodo asincrono per assicurarsi che l'infrastruttura asincrona inclusa in TestDbSet funzioni.
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);
}
}
}