Групповое тестирование с запросами, определенными в методах расширения

В моем проекте я использую следующий подход для запроса данных из базы данных:

  • Используйте общий репозиторий, который может возвращать любой тип и не привязан к одному типу, т.е. IRepository.Get<T> вместо IRepository<T>.Get. NHibernates ISession является примером такого репозитория.
  • Используйте методы расширения на IQueryable<T> с определенным T для инкапсуляции повторяющихся запросов, например

    public static IQueryable<Invoice> ByInvoiceType(this IQueryable<Invoice> q,
                                                    InvoiceType invoiceType)
    {
        return q.Where(x => x.InvoiceType == invoiceType);
    }
    

Использование будет таким:

var result = session.Query<Invoice>().ByInvoiceType(InvoiceType.NormalInvoice);

Теперь предположим, что у меня есть открытый метод, который я хочу проверить, который использует этот запрос. Я хочу проверить три возможных случая:

  • Запрос возвращает 0 фактур
  • Запрос возвращает 1 счет-фактуру
  • Запрос возвращает несколько счетов-фактур

Теперь моя проблема: что насмехаться?

  • Я не могу издеваться над ByInvoiceType, потому что это метод расширения, или я могу?
  • Я не могу даже высмеять Query по той же причине.

Ответы

Ответ 1

После нескольких исследований и основанных на ответах здесь и на эти , я решил полностью перепроектировать свой API.

Основная концепция - полностью запретить пользовательские запросы в бизнес-коде. Это решает две проблемы:

  • Улучшена тестируемость
  • Проблемы, описанные в Отметить сообщение в блоге, больше не может быть. Бизнес-уровень больше не нуждается в неявных знаниях о хранилище данных, которое используется для определения того, какие операции разрешены на IQueryable<T>, а какие нет.

В бизнес-коде запрос теперь выглядит следующим образом:

IEnumerable<Invoice> inv = repository.Query
                                     .Invoices.ThatAre
                                              .Started()
                                              .Unfinished()
                                              .And.WithoutError();

// or

IEnumerable<Invoice> inv = repository.Query.Invoices.ThatAre.Started();

// or

Invoice inv = repository.Query.Invoices.ByInvoiceNumber(invoiceNumber);

На практике это выполняется следующим образом:

Как сказал Vytautas Mackonis в его ответе, я больше не зависим от NHibernate ISession, вместо этого теперь я зависим от IRepository.

Этот интерфейс имеет свойство с именем Query типа IQueries. Для каждого объекта, к которому необходимо запросить бизнес-уровень, есть свойство в IQueries. Каждое свойство имеет свой собственный интерфейс, который определяет запросы для объекта. Каждый интерфейс запросов реализует общий IQuery<T> интерфейс, который, в свою очередь, реализует IEnumerable<T>, что приводит к очень чистому синтаксису DSL, показанному выше.

Некоторые коды:

public interface IRepository
{
    IQueries Queries { get; }
}

public interface IQueries
{
    IInvoiceQuery Invoices { get; }
    IUserQuery Users { get; }
}

public interface IQuery<T> : IEnumerable<T>
{
    T Single();
    T SingleOrDefault();
    T First();
    T FirstOrDefault();
}

public interface IInvoiceQuery : IQuery<Invoice>
{
    IInvoiceQuery Started();
    IInvoiceQuery Unfinished();
    IInvoiceQuery WithoutError();
    Invoice ByInvoiceNumber(string invoiceNumber);
}

Этот свободный синтаксис запросов позволяет бизнес-уровню объединить предоставленные запросы, чтобы в полной мере использовать возможности ORM, позволяющие фильтровать базу данных как можно больше.

Реализация для NHibernate будет выглядеть примерно так:

public class NHibernateInvoiceQuery : IInvoiceQuery
{
    IQueryable<Invoice> _query;

    public NHibernateInvoiceQuery(ISession session)
    {
        _query = session.Query<Invoice>();
    }

    public IInvoiceQuery Started()
    {
        _query = _query.Where(x => x.IsStarted);
        return this;
    }

    public IInvoiceQuery WithoutError()
    {
        _query = _query.Where(x => !x.HasError);
        return this;
    }

    public Invoice ByInvoiceNumber(string invoiceNumber)
    {
        return _query.SingleOrDefault(x => x.InvoiceNumber == invoiceNumber);
    }

    public IEnumerator<Invoice> GetEnumerator()
    {
        return _query.GetEnumerator();
    }

    // ...
} 

В моей реальной реализации я извлек большую часть кода инфраструктуры в базовый класс, так что очень легко создать новый объект запроса для нового объекта. Добавление нового запроса к существующей сущности также очень просто.

Самое приятное в том, что бизнес-уровень полностью свободен от логики запросов, и, таким образом, хранилище данных можно легко переключить. Или можно реализовать один из запросов с использованием API критериев или получить данные из другого источника данных. Бизнес-уровень не будет забывать об этих деталях.

Ответ 2

В этом случае вы должны издеваться над ISession. Но реальная проблема заключается в том, что вы не должны иметь ее как прямую зависимость. Он убивает тестируемость так же, как и SqlConnection в классе, - тогда вам придется "издеваться" над самой базой данных.

Оберните ISession с помощью какого-либо интерфейса, и все станет легко:

public interface IDataStore
{
    IQueryable<T> Query<T>();
}

public class NHibernateDataStore : IDataStore
{
    private readonly ISession _session;

    public NHibernateDataStore(ISession session)
    {
        _session = session;
    }

    public IQueryable<T> Query<T>()
    {
        return _session.Query<T>();
    }
}

Затем вы можете издеваться над IDataStore, возвращая простой список.

Ответ 3

Чтобы изолировать тестирование только до метода расширения, я бы ничего не издевался. Создайте список счетов-фактур в списке() с предопределенными значениями для каждого из трех тестов, а затем вызовите метод расширения на fakeInvoiceList.AsQueryable() и проверьте результаты.

Создание сущностей в памяти в fakeList.

var testList = new List<Invoice>();
testList.Add(new Invoice {...});

var result = testList().AsQueryable().ByInvoiceType(enumValue).ToList();

// test results

Ответ 4

Если это соответствует вашим условиям, вы можете захватить дженерики, чтобы перегрузить методы расширения. Возьмем следующий пример:

interface ISession
{
    // session members
}

class FakeSession : ISession
{
    public void Query()
    {
        Console.WriteLine("fake implementation");
    }
}

static class ISessionExtensions
{
    public static void Query(this ISession test)
    {
        Console.WriteLine("real implementation");
    }
}

static void Stub1(ISession test)
{
    test.Query(); // calls the real method
}

static void Stub2<TTest>(TTest test) where TTest : FakeSession
{
    test.Query(); // calls the fake method
}

Ответ 5

в зависимости от вашей реализации Repository.Get, вы можете высмеять NHibernate ISession.

Ответ 6

Я вижу ваш IRepository как "UnitOfWork" и ваш IQueries в качестве "репозитория" (возможно, это бесплатный репозиторий!). Поэтому просто следуйте шаблону UnitOfWork и Repository. Это хорошая практика для EF, но вы можете легко реализовать свои собственные.

Ответ 7

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

Мы делаем это в нашем текущем проекте уже более года, и всем это нравится. В большинстве случаев вашим репозиториям нужен только один метод, например

IEnumerable<MyEntity> GetBySpecification(ISpecification<MyEntity> spec)

И это очень легко насмехаться.

Изменить:

Ключ к использованию шаблона с OR-Mapper, например, NHibernate, показывает, что ваши спецификации отображают дерево выражений, которое поставщик ORM Linq может анализировать. Пожалуйста, перейдите по ссылке на статью, упомянутую выше, для получения более подробной информации.

public interface ISpecification<T>
{
   Expression<Func<T, bool>> SpecExpression { get; }
   bool IsSatisfiedBy(T obj);
}

Ответ 8

Ответ: (IMO): вы должны высмеивать Query().

Предостережение: я говорю об этом в полном незнании того, как здесь задается Query - я даже не знаю NHibernate и определяется ли он как виртуальный.

Но это, вероятно, не имеет значения! В основном я бы сделал:

-Mock Query, чтобы вернуть mock IQueryable. (Если вы не можете издеваться над запросом, потому что он не виртуальный, то создайте свой собственный интерфейс ISession, который выдает макет запроса и т.д.) - Макет IQueryable фактически не анализирует передаваемый запрос, он просто возвращает некоторые заранее определенные результаты, которые вы указываете при создании макета.

Все вместе, это в основном позволяет вам издеваться над вашим методом расширения, когда захотите.

Подробнее об общей идее выполнения запросов метода расширения и простой реализации в стиле IQueryable см. здесь:

http://blogs.msdn.com/b/tilovell/archive/2014/09/12/how-to-make-your-ef-queries-testable.aspx