Подделка базы данных(EF + nUnit)

Прежде всего стоит оговориться, что данная заметка - шпаргалка по теме, а вовсе не объяснение и разбор деталей. Потому, если есть желание именно разобраться что к чему, придется либо вчитываться в код самому, либо обратиться ко мне за разъяснениями.

Для реализации описанного ниже подхода необходимо прежде установить рекомендуемые библиотеки.

Настройка тестируемого проекта

В целях уменьшения листинга кода в статье, мы будем работать только с одной таблицей, назовем ее... скажем, Student.

В приложении имеем, соответственно, класс

public class Student : BaseEntity
{
    public string Name {get;set;}
}

и

public class BaseEntity
{
    public int Id {get;set;}
}

О природе последнего поговорим чуть позже, сейчас же ограничимся тем, что в абсолютном большинстве моделей БД ключевым идентификатором выступает числовой Id. Если же ваш случай выходит за границы этой ситуации... В данной статье мы не будем рассматривать этот вариант (если не получится разобраться - можете написать мне через форму обратной связи), ориентируясь на самые распространненные ситуации.

Класс контекста почти не претерпит изменений:

public class DataContext : DbContext, IDataContext
{
    public DataContext() : base("DataContext")
    {
    }
    
    public DbSet<Student> Students { get; set; }
}

Важное отличие от обычной реализации - наличие IDataContext:

public interface IDataContext : IDisposable
{
    DbSet<Student> Students { get; set; }
    Task<int> SaveChangesAsync();
    int SaveChanges();
}

Именно этот интерфейс будет использоваться во всех DI в приложении, о прямом использовании DataContext предлагаю забыть, подробнее о DI в целях тестирования поговорим, полагаю в ближайших статьях.

С рабочим проектом все, переходим к тестируюему коду и здесь предстоит написать (а вам скорее скопировать/вставить :) ) много больше...

Предлагаю создать в корне проекта (тестирующего) папку Context и положить туда следующие классы:

internal class FakeContext : IDataContext
{
    public FakeContext()
    {
        Students = new FakeDbSet<Student>();
    }

    public void Dispose()
    {
    }
   
    public DbSet<Student> Students { get; set; }

    public int SaveChanges()
    {
        return 1;
    }

    public async Task<int> SaveChangesAsync()
    {
        return 1;
    }
}
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 FakeDbSet<TEntity> : DbSet<TEntity>, IQueryable
    , IEnumerable<TEntity>, IDbAsyncEnumerable<TEntity>
    where TEntity : BaseEntity
{
    ObservableCollection<TEntity> _data;
    IQueryable _query;

    public FakeDbSet()
    {
        _data = new ObservableCollection<TEntity>();
        _query = _data.AsQueryable();
    }

    public override TEntity Add(TEntity item)
    {
        item.Id = _data.Count;
        _data.Add(item);
        return item;
    }

    public override IEnumerable<TEntity> AddRange(IEnumerable<TEntity> entities)
    {
        foreach (var item in entities)
        {
            item.Id = _data.Count;
            _data.Add(item);
        }
        return entities;
    }

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

FakeContext мы будем использовать в тестирующем коде для подделки нашей базы данных, в него придется добавлять все сущности БД (в нашем случае обошлись одной). Про TestDbAsyncQueryProvider говорить без толку, скопировали - работает. FakeDbSet... Тоже нет особого смысла обсуждать - в бытовых ситуациях он просто есть, позволяет писать тесты и это уже счастье) Сложности начинаются в случаях когда наши модели не соответствуют логике класса BaseEntity. Как уже говорилось, решение таких проблем - вне рамок этого опуса.

Давайте рассмотрим пример использования в выдуманном StudentService:

   [TestFixture]
    public class StudentServiceTests
    {
        private IDataContext _context;
        private StudentService _service;

        [SetUp]
        public void SetUp()
        {
            _context = new FakeContext();
            _service = new StudentService(_context);
        }
        
        [Test, AutoData, CustomFixture]
        public void GetStudents_Success(List<Student> students)
        {
            const int count = students.Count;
            _context.Students.AddRange(students);
            //
            var result = _service.Get();
            //
            Assert.True(result.Count == count);
        }
    }