Sunday, October 12, 2008

Unit-тестирование для Entity Framework

Как вы уже наверно догадались по тематике моих нескольких предыдущих постов, я сейчас активно занимаюсь изучением и внедрением unit-тестов на своем проекте. Сегодня я хотел бы поделиться решением одной из самых трудных задач, с которой мне пришлось столкнуться, а именно реализацией поддержки unit-тестирования в Entity Framework.

Если вы сейчас погуглите по этой теме, то найдете мало ответов на простой вопрос: как заставить unit-тесты работать с Entity Framework. Думаю, есть несколько вариантов решения, но я бы хотел поделиться нашим. Его преимущество в том, что он не использует базу. Можно долго спорить на тему того, как нужно тестировать Data Access Layer, чтобы код лез в базу или не лез туда. У варианта с использованием базы есть как свои достоинства, так и недостатки. Лично я считаю, что unit-тесты должны покрывать полностью лишь код DAL'а, если же вы хотите заодно проверять и базу – напишите небольшое количество integration-тестов, которые будут проверять соединение, а также какой-то минимальный набор CRUD-запросов, чтобы при случае найти ошибки и здесь. Мы же дальше будем рассматривать тестирование без базы данных.

Итак, в чем же, собственно, состоит проблема? Дело в том, что Entity Framework, к сожалению, проектировался не совсем для того, чтобы код, содержащий вызовы к нему, можно было легко протестировать – слишком уж завязаны сущности на контекст и слишком уж много функций контекст берет на себя (куда ж без этого):

  • сгенерированные ObjectQuery<T>-свойства для доступа к данным обращаются напрямую к базе данных
  • не поддерживает POCO (Plain Old CLR Objects), все объекты содержат navigation properties и Load-методы, которые также обращаются к базе
  • не поддерживает принцип Persistence Ignorance, то есть все объекты сами знают, как себя сохранять

Однако, несмотря на все это, Entity Framework обладает дополнительными полезными свойствами, которые нам помогут:

  • EF кеширует сущности в памяти, более того, позволяет работать с контекстом, как датасет: у сущностей есть состояния (unchanged, inserted, updated, deleted и др.), а также метод AcceptAllChanges, который переводит добавленные или измененные объекты в состояние unchanged
  • EF не поддерживает lazy loading, который бы мог привести к проблемам
  • контекст EF, основываясь на схеме модели, имеет достаточное количество информации о типах связей между сущностями, constraint'ах, not null-полях и т.д., чтобы автоматически проверять, все ли вы верно проинициализировали перед тем, как засылать сущность в базу данных, причем делает это даже когда вы не работаете с базой, а вызываете AcceptAllChanges()
  • внутренний API контекста достаточно открыт, чтобы самому производить доступ к закешированным сущностям, обходя стандартные ObjectQuery-свойства

Итак, как же нам нужно модифицировать наш код, чтобы он стал тестабельным?

  1. Прежде всего, для нашего же удобства нам стоит выделить код, работающий с IQueryable, ObjectQuery и IRelatedEnd.Load в отдельный слой – Data Access Layer. Не важно, как вы это реализуете, лишь бы у вас был контроль над теми местами, где идут запросы к EF. Если вы это уже сделали, good for you.
  2. Далее нужно перевести контекст в так называемый metadata-only режим, который исключает доступ к базе данных вообще. Перевод этот производится урезанием строки соединения до путей к метаданным и названия провайдера. То есть строку соединения с базой мы не пишем.
  3. Для тех случаев, когда мы работаем с unit-тестами, нужно заменить вызовы SaveChanges на AcceptAllChanges
  4. Для тех случаев, когда мы работаем с unit-тестами, нужно реализовать получение данных исключительно из ObjectStateManager, т.е. из памяти, а не из автосгенерированных ObjectQuery-свойств.
  5. Для тех случаев, когда мы работаем с unit-тестами, исключаем из кода любые вызовы IRelatedEnd.Load, то есть все обращения к Load-методу из navigation-свойств сущностей.
  6. Если был реализован lazy loading (через все те же IRelatedEnd.Load), необходимо его отключить.

Возникает вопрос: как ЭТО ВСЕ реализовать физически в коде? Не писать же отдельные методы доступа к данным для unit-тестов. Ответ прост: это можно реализовать при помощи Dependency Injection. То есть основная идея такова: нужно реализовать специальный интерфейс, содержащий методы GetEntities<T>, GetEntity<T> и ApplyPendingChanges, а также как минимум двух его наследников: одного для реального доступа к базе, другого – для работы с контекстом в памяти. Все, наши методы DAL, естественно, будут использовать именно эти методы, а вот какой объект будет инициализирован в DAL – это уже зависит от того, находимся мы в реальном приложении, или в режиме тестирования.

Я решил приготовить пример кода вместо того, чтобы делать еще один длиннющий пост с подробностями. Реализация достаточно проста, поэтому, думаю, кому нужно, те разберутся и без моей помощи :) В примере используется контекст на базе данных Northwind, в качестве unit-test framework'а я использовал NUnit. Если будут вопросы по коду – пишите в комментариях. Будет много вопросов – напишу отдельный пост с разъяснениями.

Собственно, пример реализации можно найти здесь:

Пример реализации поддержки unit-тестирования в Entity Framework

Удачи всем!

Upd. В ответ на справедливое замечание Миши Чалого о том, что в коде нифига не DI, изменил код, чтобы DI там было :) Кроме того, вынес знание об инициализации контекста и mock-обертку в проект unit-теста, где они и должны быть. Код стал более правильным, так что прошу скачивать и использовать вторую версию по той же ссылке.

15 comments:

  1. Здравствуйте, недавно начал читать ваш блог, очень полезное занятие.

    Использую в своём проекте EF и с недавнего времени пытаюсь практиковать TDD, пока не очень получается.

    Скачал проект к данной посту. Возник вопрос. Вы тестировали реализацию метода

    ContextEntitiesWrapper.GetSingleEntity ?
    В данном методе, если помните, есть код:

    // try to find entity in normal EF items
    TEntityType entity =
    (from o in objectQuery
    where o.ID == entityId
    select o).FirstOrDefault();

    Который ругается следующем экспшеном:

    The specified type member 'ID' is not supported in LINQ to Entities. Only initializers, entity members, and entity navigation properties are supported.

    Как обойти данное ограничение EF при этом не хотелось бы отказываться от использования IIdentification интерфейса?

    Спасибо за ответ.
    dmitry.n.ilin[AT]gmail.com

    ReplyDelete
  2. Здравствуйте, Дмитрий.

    Конечно, тестировал. Только что даже еще раз запустил перепроверил - работает. У вас проблемы именно с тестовым проектом или с вашим личным?

    Посмотрел, откуда могут расти ноги у вашего исключения. Пишут разное, например:

    http://mosesofegypt.net/post/LINQ-to-Entities-Workarounds-on-what-is-not-supported.aspx
    http://thedatafarm.com/blog/data-access/a-few-things-you-can-t-do-with-ef-queries-which-you-won-t-find-out-until-runtime/

    Возможно, ваше поле ID не задано в модели, вы его просто дописали к вашей сущности, как-то так:

    public int ID
    {
    get { return this.CategoryID; }
    set { this.CategoryID = value;}
    }

    Если да, то, как пишут в приведенных ссылках, причина может быть в том, что если поле не является частью концептуальной модели данных, то EF не сможет его скомпилировать в правильный запрос к базе данных - он просто не знает, во что превратить ваш кастомный код проперти на уровне SQL. Поэтому подобный код и не сработает.

    Но, в то же время в моем тесте поле ID у Customer тоже является проксиком к CustomerID и все работает :) Так что надо искать глубже.

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

    А там будем разбираться.

    ReplyDelete
  3. Как только получил исключение в своём проекте то начал поиск и ссылки эти тоже читал, но решил написать вам после того как ваш проект тоже выдал исключение (я в нём ничего не менял, кроме entity кон.стринг разумеется :))

    В принципе смысл исключения мне вполне ясен, а решение ещё нет. Я уже грешным делом полистал ADO.NET team blog нашёл там пример Unit тестирования EF но 4ой версии (http://blogs.msdn.com/adonet/pages/walkthrough-test-driven-development-with-the-entity-framework-4-0.aspx). Там заявляют что теперь всё "намного проще", вы проверяли насколько проще?

    Извините, не нашёл куда вам выслать код (и в каком виде)

    ReplyDelete
  4. Слукавил вам про то что ничего не менял. Менял, делал вот такой вызов
    class Program
    {
    static void Main(string[] args)
    {
    DomainLogic logic = new DomainLogic();
    //List customers = logic.GetCustomers().ToList();
    var q = logic.GetCustomer("dfddf");
    //foreach (Customer customer in customers)
    //{
    // Console.Out.WriteLine(customer.ContactName);
    //}
    }
    }

    ReplyDelete
  5. Я понял, в чем проблема. У вас не работали не тесты, как я думал с самого начала, а само приложение, то есть реальный EF. Попробовал ваш код - у меня та же ошибка, и как раз по тем причинам, которые описывались в предыдущем моем комментарии. То есть проблема вот в этом вот прокси-свойстве, которой прекрасно срабатывает в замоченном EF контексте, и не работает в реальном:

    public object ID
    {
    get { return CustomerID; }
    }

    Решить проблему получилось, но их там было на самом деле две: прокси-свойство и тип данных object.

    Я хотел добиться универсальности и обрабатывать все типы ключей: и string, и int, и разные имена, но это не работает. В нашем рабочем варианте везде был int, поэтому проблем не было, когда переделывал на Northwind, решил, что object решит все проблемы. Не решает.

    ReplyDelete
  6. Итак, у вас два варианта:

    1. Если нужно продолжать использовать EF1, шаги:
    а) Переименовываете в вашей модели поля типа Customer.CustomerID на ID, чтобы EF знал о вашем маппинге и корректно его обрабатывал. Соответственно убираете везде в partial сущностях прокси. Это фиксит один exception.
    б) Если у вас все ключи одного типа (Guid, string, int) - все ОК, исправляете в моем примере везде использование object на ваш тип данных (в том числе в IIdentication). Таким образом фиксите второй exception, который у вас появится после первого шага.
    в) Если у вас ключи разного типа - придется создавать свой GetEntity метод для каждого типа ключей и разные IIdentication - с object работы не будет.

    2. Переходить на EF4, что я вам настоятельно рекомендую. EF4 мощнее, взрослее, в нем нормальная поддержка Persistence Ignorance => соответственно намного более простое тестирование, и много других классных плюшек. EF4 будет зарелизен с .NET 4.0, а он уже тоже можно считать, что вышел. Так что если позволяет проект и время, то вперед.

    Я уже писал про новые возможности EF4 (http://merle-amber.blogspot.com/2009/08/entity-framework-40.html) - советую почитать сначала этот краткий обзор, а потом переходить по ссылкам внутри на полноценные посты ADO.NET team и других блоггеров, и читать подробнее.

    ReplyDelete
  7. И да, большое спасибо, что обнаружили баг :)

    Самое странное, что по статистике google code проект скачали больше 150 раз. Видимо, вы один из первых, кто его реально попробовал в боевых условиях.

    Будут еще вопросы - пишите, постараюсь помочь, чем смогу.

    ReplyDelete
  8. Александр, спасибо за исчерпывающие ответы, скорее всего буду переходить на 4ый ЕФ.

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

    ReplyDelete
  9. Александр, воспользуюсь вашим предложением, есть ещё вопрос.
    Мне в ближайшем будущем предстоит работать над одним проектом и я решил попробовать использовать следующую архитектуру из данной статьи ( http://msdn.microsoft.com/ru-ru/magazine/cc700340.aspx ) в своём приложении.
    Скажите, как опытный разработчик, стоит ли тратить усилия на то чтобы использовать TDD в проекте с подобной архитектурой? Я уже начал кое что смотреть/писать, но в принципе ещё не далеко зашёл и пока ещё есть время что-то изменить (вплоть до того что заново начать), так как дедлайны ещё речь не заходила.

    Может заодно посоветуете какие-то ресурсы или статьи по использованию EF в N-tier.

    P.S. А вообще вопросов много... :)

    ReplyDelete
  10. Выбор архитектуры очень сильно зависит от специфики вашего проекта: требований, размера, ожидаемых нагрузок. Какое у вас приложение: веб/десктоп/распределенное? Какого размера (оценка в человеко-месяцах)? Какие нагрузки, если это веб?

    Навскидку я могу сказать, что для обычных ASP.NET-приложений и определенного класса десктоп-приложений WCF-прослойка абсолютно не нужна - она лишь увеличит трудозатраты, усложнит приложение и существенно уменьшит производительность. Так что маловероятно что она вам нужна.

    Если вы только начинаете самостоятельно проектировать приложения, я бы посоветовал классическую трехслойку: Presentation Layer, Domain (Business Logic) Layer, Data Access Layer. Если у вас SL/WPF приложение - почитайте про паттерн MVVM, если WinForms или ASP.NET WebForms - про MVP. Для веб-приложений есть также хорошая альтернатива - ASP.NET MVC.

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

    По EF в N-tier. У вас точно N-tier или, может, N-layer? Нужно ли передавать данные за рамки процесса или даже клиента/сервера? Если все-таки да, нужно, тогда нужно смотреть на требования, возможно, стоит посмотреть на Self-tracking entities (EF 4.0). Вот еще одна очень небольшая статья по этому поводу:

    http://blogs.msdn.com/adonet/archive/2009/05/14/sneak-preview-n-tier-development-with-entity-framework-4-0.aspx

    Также почитайте про Self-tracking entities.

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

    Планируется разработка очередного десктоп (WinForms) приложения для "собственных нужд" роль которого, по сути просто предоставлять GUI к БД (MSSQL). Никакой особо сложной бизнес логики там не будет, просто редактирование нормативных данных (о лечебных учреждениях). Приложения будут целиком распологаться на юзерских машинах и дёргать БД.

    Немного лирики.

    Вообще задачи интересные, связанные с анализом медицинских даных и уже нароботки есть.
    Но работадатели вялые, им известно только слово "когда", поэтому всё приходиться делать самому, весь цикл разработки ПО (таковы суровые девелоперские реалии провинциальных городишек). А опыта промышленной разработки малова-то, поэтому мне интересно ВСЁ и хочеться поиметь как можно больших реальных скилов и скорее выбраться в ближайший мегаполис (в аутсорсинговую контору). Думаю вы так или иначе через это проходили.
    Этим обусловлен выбор той архитектуры, ведь там было много нового :) Тут же хочеться и TDD попробовать как методику разработки.

    Спасибо Александр, думаю что сделаю как можно проще и быстрее (с трёхслойкой и EF) а новое всё таки буду пробовать дома...

    Сори, если сказал лишнего. Наболело...

    ReplyDelete
  12. В таком случае могу посоветовать следующую архитектуру. На сервере у вас будет база данных, но с клиентским приложений (WinForms) лучше обращаться не напрямую к ней, а к методам специально написанного веб-сервиса (WCF). В таком случае на сервере у вас будет логика и доступ к данным, а на клиенте - представление, валидация и т.д.

    Еще есть вариант наружу выставить ADO.NET Data Services, благо, они как раз на EF и базируются. По сути, это CRUD-методы, доступные через REST интерфейс. Если у вас там нет особых отходов в сторону от базовой модели работы, то программа будет существенно проще (бОльшая часть серверного кода будет сгенерирована автоматически по модели EF). Тогда логика (которой у вас и так немного по вашим словам) переедет на клиент, что, как мне кажется, сильно вам не помешает.

    Я бы посоветовал второй вариант. Он быстрее в разработке + изучите интересную технологию :)

    На клиенте все-таки посоветовал бы посмотреть на паттерн MVP, но это уже смотрите сами - если UI простой, можно и не заморачиваться.

    TDD - по желанию. Если логики мало - он вам мало поможет, а проблем создаст однозначно. Лучше попробуйте написать модульные тесты для тех классов бизнес-логики, которые есть. Набъете руку - можно будет и на TDD смотреть. Обязательно попробуйте Rhino Mocks или Moq.

    Ну, и удачи во всем остальном! :) Я раньше немного писал по поводу профессионального развития, может, вам поможет.

    ReplyDelete
  13. Спасибо за помощь.

    Гудлак :)

    ReplyDelete
  14. Не за что :) Только, похоже, я вам ерунду какую-то посоветовал. Только что дочитал, что "Приложения будут целиком распологаться на юзерских машинах и дёргать БД". Если БД на той же машине, что и приложение, то вам, конечно же, не нужны никакие WCF и ADO.NET Data Services. Меня немного сбило с толку то, что вы сначала говорили про N-tier, вот и думал в разрезе удаленного доступа :)

    Хотя даже если у вас будет одна база данных и удаленные подключения, то вариант обращения напрямую к базе данных без посредников никто не отменял :) Но это лишь в том случае, если сеть локальна и там все нормально с безопасностью. Так что, с большой долей вероятности, вам не нужно городить огород из ADO.NET Data Services и тем более WCF. В общем, вариантов много и решать, конечно, вам.

    ReplyDelete
  15. Вы правильно поняли.
    Всё взаимодействие происходит в границах LAN с одним сервером баз данных.

    Думаю сделаю классический вариант, без огорода.
    Ещё раз спасибо за ценные советы.

    Ещё напишу вам.

    ReplyDelete