Многие, использующие Entity Framework сталкиваются с проблемой изоляции внешних зависимостей (в данном случае БД) при написании юнит-тестов. В этом примере я покажу, как подменить
DbContext прозрачно для вызывающего кода, и заполнить поддельный
DbSet тестовыми данными. Такой прием полезен для тестирования кода доступа к данным в изоляции от БД. Актуально для Entity Framework 6.
Мы будем тестировать популярный паттерн: некий класс-"репозиторий", инкапсулирующий в себе запрос к
DbSet, транслируемый в запрос к базе данных:
public class ReportRepository
{
[Inject]
public IContextFactory ContextFactory { get; set; }
public List<Report> GetReports()
{
using (var context = new FooContext())
{
return context.Reports.ToList();
}
}
}
Этот код не назовешь слабосвязанным, так как присутствует зависимость от конкретной реализации контекста - класса
FooContext. Нам необходимо в первую очередь избавиться от этой зависимости. Для этого модифицируем наш контекст, выделив необходимые методы и свойства в интерфейс (например, путем
модификации шаблонов T4, генерирующих контекст):
public partial class FooContext : DbContext, IFooContext
{
public IDbSet<Report> Reports { get; set; }
}
Однако, класс-репозиторий сам управляет временем жизни контекста, поэтому прямое внедрение интерфейса не подходит. Вместо этого, используем паттерн "абстрактная фабрика" (все подробности - в известной
книге "Dependency Injection on .NET", сейчас практически стандарту по DI в дотнете). Итак, наша фабрика будет создавать новые экземпляры контекста:
public class ContextFactory : IContextFactory
{
public IFooContext Create()
{
return new FooContext();
}
}
Внедрим фабрику в "репозиторий":
public class ReportRepository
{
[Inject]
public IContextFactory ContextFactory { get; set; }
public List<Report> GetReports()
{
using (var context = ContextFactory.Create())
{
return context.Reports.ToList();
}
}
}
Теперь использующий контекст не знает ничего о конкретной реализации контекста. Осталось только сконфигурировать наш DI контейнер (здесь и далее используется Ninject):
public class AppModule : NinjectModule
{
public override void Load()
{
Bind<IContextFactory>().To<ContextFactory>();
}
}
Самое время выполнить интеграционный тест, запрашивающий данные из реальной БД:
[Test]
public void RealRepository_WhenCalled_ReturnsEmpty()
{
var kernel = new StandardKernel();
kernel.Load<AppModule>();
var repo = kernel.Get<ReportRepository>();
var reports = repo.GetReports();
Assert.IsEmpty(reports);
}
Переходим непосредственно к подмене контекста. Для этого нам понадобится еще один модуль Ninject, который достаточно создать в проекте с изолированными от БД тестами:
public class MockModule : NinjectModule
{
public override void Load()
{
Rebind<IContextFactory>().To<MockContextFactory>();
}
}
Для создания поддельных объектов используется фреймворк Moq. Обратите внимание на реализацию
MockContextFactory: вся хитрость - в методе
MockDbSet<T>, который создает мок-объект, реализующий
IQueryable<T>:
public class MockContextFactory : IContextFactory
{
public IFooContext Create()
{
var mockRepository = new MockRepository(MockBehavior.Default);
var mockContext = mockRepository.Create<IFooContext>();
mockContext.Setup(x => x.SaveChanges())
.Returns(int.MaxValue);
List<Report> mockReports = MockReports();
var mockDbSet = MockDbSet<Report>(mockReports);
mockContext.Setup(m => m.Reports).Returns(mockDbSet.Object);
return mockContext.Object;
}
private List<Report> MockReports()
{
List<Report> mockReports = new List<Report>();
mockReports.Add(new Report {Id = 1, Name = "Mock Report #1"});
return mockReports;
}
private Mock<DbSet<T>> MockDbSet<T>(List<T> data = null)
where T : class
{
if (data == null) data = new List<T>();
var queryable = data.AsQueryable();
var mock = new Mock<DbSet<T>>();
mock.As<IQueryable<T>>().Setup(m => m.Provider)
.Returns(queryable.Provider);
mock.As<IQueryable<T>>().Setup(m => m.Expression)
.Returns(queryable.Expression);
mock.As<IQueryable<T>>().Setup(m => m.ElementType)
.Returns(queryable.ElementType);
mock.As<IQueryable<T>>().Setup(m => m.GetEnumerator())
.Returns(queryable.GetEnumerator());
return mock;
}
}
Этого достаточно! Мы можем обращаться к поддельному
DbSet, получать тестовые данные через LINQ, при этом подмена контекста останется незамеченной для использующего контекст кода. Для проверки напишем еще один тест:
[Test]
public void RealRepository_WhenCalled_ReturnsMockData()
{
var kernel = new StandardKernel();
kernel.Load<AppModule>();
//Order is important. In MockModule we rebind IContextFactory.
//Another option is to load MockModule only.
kernel.Load<MockModule>();
var repo = kernel.Get<ReportRepository>();
var reports = repo.GetReports();
Assert.IsNotEmpty(reports);
}
Данный способ - не 100% пацея и побочные эффекты могут возникнуть, поскольку в LINQ-запросе к
DbSet будет использоваться LINQ to Objects, а не LINQ to Entities.
Полный пример для EF6 + SQL Server доступен на
GitHub.