Тестирование с помощью собственных двойных тестов
Примечание.
Только в EF6 и более поздних версиях. Функции, API и другие возможности, описанные на этой странице, появились в Entity Framework 6. При использовании более ранней версии могут быть неприменимы некоторые или все сведения.
При написании тестов для приложения часто желательно избежать попадания в базу данных. Entity Framework позволяет достичь этого путем создания контекста ( с поведением, определенным тестами), который использует данные в памяти.
Параметры создания тестовых двойных
Существует два различных подхода, которые можно использовать для создания в памяти версии контекста.
- Создайте собственный тест в два раза . Этот подход включает написание собственной реализации в памяти контекста и DbSets. Это дает вам много контроля над поведением классов, но может включать написание и владение разумным объемом кода.
- Используйте платформу макетирования для создания тестовых двойников . С помощью макетной платформы (например, Moq) можно использовать реализации контекста в памяти и наборы, созданные динамически во время выполнения.
В этой статье рассматривается создание собственного тестового двойника. Сведения об использовании макетной платформы см. в статье "Тестирование с помощью Mocking Framework".
Тестирование с предварительной версией EF6
Код, показанный в этой статье, совместим с EF6. Тестирование с помощью EF5 и более ранней версии см. в разделе "Тестирование с помощью поддельных контекстов".
Ограничения теста EF в памяти двойны
Двойные тесты в памяти могут быть хорошим способом обеспечить охват битов вашего приложения, использующих EF. Однако при этом используется LINQ to Objects для выполнения запросов к данным в памяти. Это может привести к поведению, отличному от использования поставщика LINQ EF (LINQ to Entity) для перевода запросов в SQL, выполняемых в базе данных.
Одним из примеров такого различия является загрузка связанных данных. Если вы создаете ряд блогов, которые имеют связанные записи, то при использовании данных в памяти связанные записи всегда будут загружаться для каждого блога. Однако при выполнении в базе данных данные загружаются только при использовании метода Include.
По этой причине рекомендуется всегда включать некоторый уровень сквозного тестирования (в дополнение к модульным тестам), чтобы приложение работало правильно с базой данных.
Далее вместе с этой статьей
В этой статье приведены полные описания кода, которые можно скопировать в Visual Studio, чтобы следовать за этим, если вы хотите. Проще всего создать проект модульного теста, и вам потребуется использовать платформа .NET Framework 4.5, чтобы завершить разделы, использующие асинхронный.
Создание интерфейса контекста
Мы рассмотрим тестирование службы, которая использует модель EF. Чтобы заменить контекст EF на версию в памяти для тестирования, мы определим интерфейс, который будет реализован в контексте EF (и в памяти двойной).
Служба, которую мы собираемся протестировать, будет запрашивать и изменять данные с помощью свойств DbSet нашего контекста, а также вызывать SaveChanges для отправки изменений в базу данных. Поэтому мы включаем эти члены в интерфейс.
using System.Data.Entity;
namespace TestingDemo
{
public interface IBloggingContext
{
DbSet<Blog> Blogs { get; }
DbSet<Post> Posts { get; }
int SaveChanges();
}
}
Модель EF
Служба, которую мы собираемся протестировать, использует модель EF, которая состоит из bloggingContext и классов Blog и Post. Этот код может быть создан конструктором EF или моделью 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; }
}
}
Реализация интерфейса контекста с помощью конструктора EF
Обратите внимание, что наш контекст реализует интерфейс IBloggingContext.
Если вы используете код сначала, вы можете изменить контекст непосредственно для реализации интерфейса. Если вы используете конструктор EF, необходимо изменить шаблон T4, который создает контекст. <Откройте файл model_name.Context.tt>, вложенный в edmx-файл, найдите следующий фрагмент кода и добавьте его в интерфейс, как показано ниже.
<#=Accessibility.ForType(container)#> partial class <#=code.Escape(container)#> : DbContext, IBloggingContext
Проверка службы
Чтобы продемонстрировать тестирование с помощью теста в памяти, мы будем писать несколько тестов для BlogService. Служба может создавать новые блоги (AddBlog) и возвращать все блоги, упорядоченные по имени (GetAllBlogs). Помимо GetAllBlogs, мы также предоставили метод, который будет асинхронно получать все блоги, упорядоченные по имени (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();
}
}
}
Создание теста в памяти двойное
Теперь, когда у нас есть реальная модель EF и служба, которая может его использовать, пришло время создать тест в памяти двойной, который мы можем использовать для тестирования. Мы создали тест TestContext double для нашего контекста. В тесте двойники мы можем выбрать поведение, которое мы хотим, чтобы поддерживать тесты, которые мы собираемся запустить. В этом примере мы просто фиксируем количество вызовов SaveChanges, но вы можете включить любую логику для проверки сценария, который вы тестируете.
Мы также создали TestDbSet, который предоставляет реализацию DbSet в памяти. Мы предоставили полную реализацию для всех методов в DbSet (за исключением Find), но вам нужно реализовать только элементы, которые будет использовать тестовый сценарий.
TestDbSet использует некоторые другие классы инфраструктуры, которые мы включили, чтобы обеспечить обработку асинхронных запросов.
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; }
}
}
}
Реализация поиска
Метод Find сложно реализовать в универсальном режиме. Если вам нужно протестировать код, который использует метод Find, проще всего создать тестовый dbSet для каждого типа сущностей, которые должны поддерживать поиск. Затем можно написать логику, чтобы найти этот конкретный тип сущности, как показано ниже.
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);
}
}
}
Написание некоторых тестов
Это все, что нам нужно сделать, чтобы начать тестирование. Следующий тест создает TestContext, а затем службу на основе этого контекста. Затем служба используется для создания нового блога с помощью метода AddBlog. Наконец, тест проверяет, добавила ли служба новый блог в свойство блогов контекста и называется SaveChanges в контексте.
Это просто пример типов вещей, которые можно протестировать с помощью двойного теста в памяти, и вы можете настроить логику теста двойниками и проверку в соответствии с вашими требованиями.
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);
}
}
}
Ниже приведен еще один пример теста — на этот раз, который выполняет запрос. Тест начинается с создания контекста теста с некоторыми данными в свойстве блога. Обратите внимание, что данные не в алфавитном порядке. Затем мы можем создать службу BlogService на основе нашего тестового контекста и убедиться, что данные, которые мы возвращаем из GetAllBlogs, упорядочены по имени.
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);
}
}
}
Наконец, мы напишем еще один тест, использующий асинхронный метод, чтобы обеспечить работу асинхронной инфраструктуры, которую мы включили в TestDbSet .
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);
}
}
}