-->

вторник, 2 февраля 2016 г.

Подменяем DbContext, изолируем базу данных для тестирования

Многие, использующие Entity Framework сталкиваются с проблемой изоляции внешних зависимостей (в данном случае БД) при написании юнит-тестов. В этом примере я покажу, как подменить DbContext прозрачно для вызывающего кода, и заполнить поддельный DbSet тестовыми данными. Такой прием полезен для тестирования кода доступа к данным в изоляции от БД. Актуально для Entity Framework 6.

Мы будем тестировать популярный паттерн: некий класс-"репозиторий", инкапсулирующий в себе запрос к DbSet, транслируемый в запрос к базе данных:
public class ReportRepository
{
 [Inject]
 public IContextFactory ContextFactory { getset; }
 
 public List<Report> GetReports()
 {
  using (var context = new FooContext())
  {
   return context.Reports.ToList();
  }
 }
}
Этот код не назовешь слабосвязанным, так как присутствует зависимость от конкретной реализации контекста - класса FooContext. Нам необходимо в первую очередь избавиться от этой зависимости. Для этого модифицируем наш контекст, выделив необходимые методы и свойства в интерфейс (например, путем модификации шаблонов T4, генерирующих контекст):
public partial class FooContext : DbContextIFooContext
{
 public IDbSet<Report> Reports { getset; }
}
Однако, класс-репозиторий сам управляет временем жизни контекста, поэтому прямое внедрение интерфейса не подходит. Вместо этого, используем паттерн "абстрактная фабрика" (все подробности - в известной книге "Dependency Injection on .NET", сейчас практически стандарту по DI в дотнете). Итак, наша фабрика будет создавать новые экземпляры контекста:
public class ContextFactory : IContextFactory
{
 public IFooContext Create()
 {
  return new FooContext();
 } 
}
Внедрим фабрику в "репозиторий":
public class ReportRepository
{
 [Inject]
 public IContextFactory ContextFactory { getset; }
 
 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 = nullwhere 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.

Комментариев нет:

Отправить комментарий