Showing posts with label Tips and Tricks. Show all posts
Showing posts with label Tips and Tricks. Show all posts

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-теста, где они и должны быть. Код стал более правильным, так что прошу скачивать и использовать вторую версию по той же ссылке.

Thursday, April 17, 2008

Show External Code

Наверно, многие уже знают этот трюк, но для меня было новостью: если в дебаге открыть окошко Call Stack, в нем вызвать контекстное меню и включить опцию Show External Code, то в окошке будут отображаться и стек вызовов внутри системных библиотек .NET Framework.



Штука очень полезная во многих случаях, поэтому рекомендую включить, кто еще не успел. Нам, например, она очень пригодилась, когда мы разбирались, из какой же страницы все-таки происходит вызов Page_Load в базовом классе :) Оказалось, совсем не из той, о которой мы думали.

Tuesday, March 25, 2008

Особенности TextBox.MaxLength

Новая рубрика: «Знаете ли вы, что?»

Скорее всего, с этим интересным моментом из жизни .NET Framework уже многие сталкивались, но для тех, кто еще не успел, пишу этот пост.

Итак, проблема: нужно не дать пользователю ввести в какое-нибудь поле ввода на странице больше символов, чем определено в базе данных, иначе вполне очевидно вылетит database exception. Имеется в виду, конечно же, случай, когда мы используем nvarchar(xx) и различные вариации на тему.

Глобально решать эту проблему можно двумя путями: не дать пользователю ввести больше символов, чем нужно, или просто урезать введенную строку (на сервере или на клиенте). Второй вариант является не очень красивым, так как пользователь ожидает, что все введенные им данные будут сохранены приложением, а они будут сохранены в урезанном виде. Кроме того, если пытаться урезать данные на сервере, то пользователь может запросто ввести больше 100 Мб текста в поле ввода, что спровоцирует exception еще на подступах к приложению (IIS 6 по умолчанию имеет ограничение в 4 Мб на передачу запросов/ответов, решение этой проблемы хорошо описано в интернете на форумах). Так что более правильным вариантом является явное ограничение размера поля на клиенте.

Казалось бы, что может быть проще. В TextBox'е есть замечательное свойство MaxLength, которое нам и нужно установить для всех наших полей ввода. В сгенерированном HTML-элементе это свойство просто становится атрибутом maxLength. Проставляем, запускаем, пробуем и радуемся. Все работает просто замечательно :). Устанавливаем баги в баг-трекинг системе и забываем о проблеме. Не тут-то было. Через время часть багов возвращается с feedback'ами. Для некоторых полей свойство почему-то не срабатывает. Перепроверяем и сразу же понимаем, в чем проблема – не срабатывают многострочные текстбоксы, которые транслируются в <textarea>, а не в <input type="text">. Смотрим MSDN и видим замечательный note от Microsoft:

Note. This property is applicable only when the TextMode property is set to TextBoxMode.SingleLine or TextBoxMode.Password.

Смотрим <input type="text"> и <textarea> в MSDN – так и есть, атрибут maxLength определен для первого и не определен для второго элемента. Почему так сделано – загадка, но нам нужно как-то с этим бороться. Быстрый поиск дает 2 варианта решения:

  1. Javascript, который не дает ввести больше элементов, чем нужно (вариантов много, например, вот и вот)
  2. Regex-валидатор, который будет проверять размер введенного текста
  3. Custom-валидатор с client-скриптом

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

Выражение для проверки длины текста выглядит очень просто: "^[\s\S]{0,XX}$", где XX – максимально допустимая длина нашего текста. Для более удобного использования можно реализовать свой валидатор на базе стандартного, например, так:

public class MaxLengthValidator : RegularExpressionValidator
{
    private int maxLength = int.MaxValue;

    public MaxLengthValidator()
    {
        int configMaxLength;
        if (int.TryParse(Config.TextBoxMultilineMaxLength, out configMaxLength))
        {
            MaxLength = configMaxLength;
        }
    }

    public int MaxLength
    {
        get { return maxLength; }
        set { maxLength = value; }
    }

    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);

        ValidationExpression = @"^[\s\S]{0," + MaxLength + "}$";
        if (ErrorMessage.Contains("{0}"))
        {
            ErrorMessage = string.Format(ErrorMessage, MaxLength);
        }
    }
}

Как нетрудно догадаться, Config.TextBoxMultilineMaxLength возвращает длину полей ввода из конфигурационного файла. Это удобно, если мы хотим контролировать максимальную длину текста для полей типа ntext и хотим сделать эту настройку system wide. Если вам это не нужно, то можно просто убрать этот код из конструктора и определять MaxLength явно. Также, если мы хотим выдавать сообщение об ошибке, которое будет содержать установленный нами лимит, то это тоже не сложно сделать в событии OnLoad, как сделано в примере. Пользоваться этим валидатором так же просто, как и другими.